GuildRoster-v1.4.0
What's new
Changelog
All notable changes to LibGuildRoster-1.0 are documented here.
1.4.0 -- allied-guild short tags, CanonName is idempotent again, and a gossiped sister list is cleaned
The LibStub MINOR moves 34 -> 35. (Begun as 1.3.1 for the fix below; the tags are a new feature, so the release is 1.4.0, still MINOR 35.)
New
- Short tags for allied guilds:
"Ahmbrai <Other>". Asked for by TOGProfessionMaster (contract49703e36), on the operator's words: "for the <old><other> that shouldn't come from you ... it should feed that to every addon that consumes a sister guild". Two addons deriving tags on their own could show one guild under two tags; the library holds the sister list, so it holds the tags.lib:GetGuildTag(nameOrKey)-> the tag for a SISTER guild, by name or"Faction-GuildName"key;nilfor the home guild and any unlisted one.lib:GetCharGuildTag(charKey)-> the tag of the allied roster a character is in (IsInAnyRoster);nilfor a home member or an unknown character.lib.DeriveGuildTags(names)->{ [name] = tag }, the pure derivation, a port of TOGPM'sDeriveGuildAbbrevs: filler words dropped (the, of, and, a, an, &, -), the first word no other guild in play shares, cut to five CHARACTERS (not bytes, so an accented letter is never split); on a collision the whole word, then the initials, then a number. Names are sorted first -- before the case-insensitive dedupe too, so even which spelling of a case-only duplicate survives is fixed (Peer Review1804345f, F3) -- so the result never depends on typing order, and no automatic tag is shared. "The Old Gods" + "The Other Gods" -> "Old" / "Other".lib:SetGuildTag(nameOrKey, tag | nil)andlib:GetGuildTagOverrides()(a copy, keyed by lower-case guild name): a per-guild override that beats the automatic tag. Stored per home guild inLibGuildRosterDBbeside the sister list (guildTags), and LOCAL -- not gossiped, because it is a reader's preference and gossip would let one member's spelling replace everyone's. Every|is removed, whitespace runs (a pasted newline included) become one space, the ends are trimmed, and the tag is cut to 12 characters (GUILD_TAG_OVERRIDE_LEN), so a tag is always plain text that fits a consumer's column (Peer Review1804345f, F2).- An override that matches another listed guild's tag (automatic or
override, case-insensitively) is STORED -- it is the player's choice --
and
SetGuildTagnames that guild as its second return, so a settings box can warn:local ok, clash = GR:SetGuildTag(name, tag)(Peer Review1804345f, F1). OnGuildTagsChanged()fires on an override change and on any sister-list change, since every automatic tag is worked out against the whole list.- KNOWN COST: the home guild's name takes part in the derivation, and it resolves a moment after login. A tag read before then can differ from the one read after, and no callback marks that moment.
Tests/guildtags_spec.lua, 23 examples.
Fixed
A sister-guild list adopted from a guildmate's gossip was not cleaned the way an officer's edit is. The gossip path dropped only non-strings and empty names, so a list from a sender that never ran the officer's cleaner could hold
"Sister Guild ", whose key matches no real guild -- that sister silently never synced -- or two case variants of one name. Both paths now share one cleaner (trim, drop empties, de-duplicate case-insensitively). Peer Review1804345f, N1; whether any sender in the wild sends a dirty list is not known.Tests/sistersync_spec.luagossips one.CanonNamedropped a stray colon AFTER trimming, so the space the colon had hidden survived one pass.CanonName("E :")was"E "andCanonName("E ")was"E": the function was not idempotent, though the comment inSetAltGroup's re-parent block relies on it being so. The failure:SetAltGroup("E :", ...)filed the group under"E ", after whichGetAltOwner("E :")wasnilwhileGetAltGroup("E :")found the group, and a consumer re-canonicalising the stored key got"E", a different name. The colon is now removed first and the trim runs on what is left. Reported by Ultimate Guild Roster (peer reviewf1827f75). Whether any real client produces such a name is not known; the colon strip exists only to repair malformed input.Tests/normalize_spec.luagains three rows ("Thrall :",": Thrall","Thrall : - Fairbanks"). The existing "is idempotent" example walks the same table, so it now covers a colon next to whitespace; before, no input in it had one. With the old order,"Thrall :"returns"Thrall "and fails that row. I worked that out from the code and did not run it to see it go red.NormalizeNamehad the same defect one layer up, and it is the one that keysGetMember,SetSisterRosterand its own cache:"Bob :"keyed as"Bob -Testrealm"(the realm appended after the surviving space), and that key normalised again to"Bob-Testrealm". The same reorder fixes it. TheNormalizeNameexamples gain a colon row and an idempotency example over eleven inputs (Peer Revieweafe6816, N1). A sister roster persisted under 1.3.0 with such a key is re-keyed on the first 1.3.1 login, because the login re-feed passes each stored name back throughSetSisterRoster.
1.3.0 -- extension points on the Members tab, and roster-manager data
The LibStub MINOR moves 33 -> 34. Additions only.
New
- Four extension points on the guild window's Members tab, both windows
(Communities and classic). Asked for by Ultimate Guild Roster (contract
fa4de08e): players coming from GRM want member tools reachable from the guild window, and the tab is the library's, shared by every consumer, so the only way in had been hookingOnGuildTabRowClick/GuildTabRowMenuItems.lib:RegisterGuildTabClickHandler(owner, fn)--fn(name, row, mouse)runs inOnGuildTabRowClickbefore the default whisper / menu;truetakes the click. Firsttruestops the walk.lib:RegisterGuildTabMenuItems(owner, fn)--fn(name, row)returns menu items appended after the library's inGuildTabRowMenuItems; non-table entries are skipped. A consumer item's tablechildrencomes back fromGuildTabRowMenuItemsas a FUNCTION that builds the protected submenu when it is opened, so a direct caller indexingchildren[1]must call it first.lib:RegisterGuildTabTooltip(owner, fn)--fn(tooltip, name, row)adds lines;ShowGuildTabRowTooltipshows GameTooltip. On the Communities rows it is aHookScript("OnEnter")after Blizzard's own row OnEnter, which stays (on our expanded rows it draws one only when a column is truncated,CommunitiesMemberList.lua:1163-1167, so ours takes ownership when theirs drew none); on the classic rows it is the OnEnter itself, andHideGuildTabRowTooltipthe OnLeave (both werenil, because theirs readGetGuildRosterInfo). With no consumer registered,ShowGuildTabRowTooltipreturns at once and the rows behave exactly as in 1.2.1.lib:GetGuildTabPage()-> the visible page and"communities"/"classic", and the callbacksOnGuildTabPageShown(kind, page)/OnGuildTabPageHidden(kind, page), which ALTERNATE:announcePagekeeps what consumers were last told inrec.announced, so a second click on our tab fires no second Shown, and anOnHidepost-hook on each window (CommunitiesFrame, FriendsFrame) fires Hidden when the window closes around the page.rec.page:IsShown()cannot carry this: closing the window hides only the parent, so the page still read shown, Hidden never fired on close, and on reopen either Shown fired twice in a row (J opens on our tab) orHideGuildTabfired a late Hidden for the earlier close (Peer Review on the self-audit, threadc3db3155). The hook goes in throughhookPageClose, once per rec underrec.hideHooked, and is called BEFORE each install'srec.pageearly return as well as at the build, so a newer copy adds it to a tab an older copy built (Peer Review, thread9ede451f, F3). Registries live inlib.guildTabHooks(survives a LibStub upgrade), are ordered lists keyed by owner (re-registering replaces in place,nilremoves), and every consumer call goes throughcallConsumer, which hands an error togeterrorhandler()and carries on.
- What a consumer is handed is a copy.
rowis a shallow copy of the row per call (rowMember), so a consumer writingrow.charKeycannot change what the row whispers or draws. A consumer's menu items are copied too (protectMenuList) and theironClick,checkedandchildrenfunctions run throughcallConsumer, nested children included, with submenus past depth 8 dropped so a list containing itself cannot overflow the stack inside the right-click. A table-valuedchildrenis protected LAZILY, as a function LibAceGUIWidgets calls on hover: copied eagerly, a self-containing list of n items was n^8 copies on one right-click, and 50 items never returned (Peer Review, thread9ede451f). LibAceGUIWidgets calls a function-valuedcheckedwhile the menu draws andchildrenon hover, unprotected, so a raisingcheckedthrew out ofOpenMenuinside our row click; itsonClickprotection exists only from its MINOR 39, which this library does not require. - The consumer tooltip adds to Blizzard's only when GameTooltip is owned by the row AND shown; owned-but-hidden is a tooltip left from an earlier hover, and adding to it would re-show its old lines.
- Specs:
Tests/tabhooks_spec.lua, 30 examples across both windows, among them close-then-reopen in both windows with the default tab on and off, the close hook added to a tab built without it, and a 50-item self-containing submenu;classictab_specnow pins the classic rows' OnEnter / OnLeave as functions. Every one of these was shown RED with its fix reverted: the owned-and-shown, Communities close-pairing, row-copy and both menu-protection specs (one drives the realOpenMenu) earlier, and the classic close-pairing, classic hook-on-an-older-tab and lazy-children specs in run20261015-113400.411, the Communities hook-on-an-older-tab spec in20261015-113531.678. - Consumer panes inside the Members page, both windows. Asked for by
Ultimate Guild Roster (contract
cf43f522): a player wanted UGR's window in the tab, and parenting into our page from outside draws over our list, which is ours to lay out.lib:RegisterGuildTabPane(owner, { label, build(parent, kind), shown?, hidden? }),lib:SelectGuildTabPane(owner|nil, kind?)andlib:GetGuildTabPane(kind); registrylib.guildTabPanes(ordered, survives a LibStub upgrade). A pane's parent is a frame of ours, child ofrec.pageso it hides with it, laid over the list body at page level +GUILD_TAB_PANE_LEVEL(30: above the template's InsetFrame border, whichLiftGuildTabPageputs at + 20, and re-levelled with it on every lift bysetLevelWithChildren, which moves the whole subtree by the same amount so the consumer's content and the switcher's buttons stay above their parent and in their own order) with a tiled marble background, the mouse and the wheel taken (an emptyOnMouseWheel, so a wheel cannot reach the hidden list), built andbuildrun the first time it is selected on that window (rec.paneFrames[owner]). The list is covered, never hidden. A switcher (rec.paneBar,UIPanelButtonTemplatebuttons, "Members" from the globalMEMBERSthen one per pane, the current view disabled) is built only once a pane is registered, so with none the page is unchanged;syncPaneBarruns at registration and at the end of both installs.shown/hiddenrideannouncePage, so a selected pane is told when the page comes and goes on the same once-per-change rule as the page callbacks, andselectPanetells them only whilerec.announced(a pane selected on a hidden page is built, and toldshownwhen the page comes up). While a pane shows, every list control OUTSIDE its rectangle is hidden: the search box and Reset on both pages, the classic page's Show Offline box, and the Communities template's own Show Offline check -- a click on either toggle changed a list the player could not see (Peer Review, thread70bed601). Unregistering the view tells ithiddenand puts the list back. Placement: Communities, the pane covers the page plus the template's column headers (page top + 1 to + 25:ColumnDisplayTemplateis 60 tall with its bottom on the page's top, and its headers are 24-tallColumnDisplayButtonTemplatebuttons at its BOTTOMLEFT 2, 1 -- the same in the classic_era, live and forever trees; Peer Review, thread299eb7fb, found the 20 first used here was 5 short) and its scroll bar (to + 22 right), becauseRefreshLayoutre-shows the headers on every refresh (CommunitiesMemberList.lua:592-613) so hiding them would not hold; the switcher hangs below at the page's BOTTOMLEFT (0, -4). Classic, the pane covers FriendsFrame (4, -57) to (-6, 26) and the switcher sits at its BOTTOMLEFT (8, 3). The switcher's buttons calllib:SelectGuildTabPaneat click time, so a later copy's code runs from buttons an older one built, andRegisterGuildTabPanerefusesfalseas an owner as well asnil.Tests/tabpanes_spec.lua, 27 examples on the widget layer (the switcher's template buttons and the search box are not modelled hollow); the hidden-page example went RED withselectPane'srec.announcedguard forced true (run20261015-141932.461), and the Communities Show Offline example went RED on my first cut, whoseipairsstopped at the classic-onlyofflineBox(nil there) and never reached the check (run20261015-143753.988). The child-level assertion went RED withsetLevelWithChildren's subtree loop switched off (expected 301, got 51; run20261015-145416.898) once the spec put a frame of theirs at + 200 so the lift really moves the page -- without that it passed either way. - Edit a guildmate's public or officer note from the Members tab's row
menu, both windows. A Discord request forwarded by Ultimate Guild Roster
(contract
0c32a43b): the tab's search finds the member, and Blizzard's roster has no search, but the menu only offered Whisper and Invite. A guildmate's row now adds "Edit public note" / "Edit officer note" wherelib:CanEditMemberNote(name, isPublic)allows: your own public note, orCanEditPublicNote(); the officer note only withC_GuildInfo.CanViewOfficerNote()ANDCanEditOfficerNote()-- Blizzard's own guild UI's rule (Blizzard_Communities/GuildRoster.lua:125,:154-156), so nobody overwrites a note they could not read. Never on a sister member's row.lib:EditMemberNote(name, isPublic)opens LibAceGUIWidgets' shared dialog, capped atlib.NOTE_MAX_LETTERS(31, the client's ownSET_GUILDPLAYERNOTEcap) for that show only, andlib:SetMemberNote(name, text, isPublic)callsC_GuildInfo.SetNote(member.guid, ...)from the dialog's OK click or Enter key and nowhere else:SetNoteisHasRestrictionsin every tree and the docs do not say which restriction, so it is driven only from player input. Offered on Classic Era, TBC / Anniversary and Mists only (lib.CAN_WRITE_NOTES, public so a consumer reads one answer): the allow-list UGR's own editor keeps (CanWriteNotes), and an unknown client -- Wrath, Cata, retail, WoW: Forever, an unseen id -- answers false. Retail and Forever addSecretArguments = "AllowedWhenUntainted"with an effect on addons nobody has established, and Wrath and Cata have no source tree on this box (Peer Review153b3518, F2; until then the gate was "notIS_RETAIL", which offered the edit on Wrath and Cata). The dialog's starting text is read from the CLIENT's roster row on the click, the way Blizzard's editor readsGetGuildRosterInfoatOnMouseUp(Classic/FriendsFrame.lua:3390), with the held note as the fallback, so a second edit in one session does not open on the pre-edit text (F4). Whether the client's row reflects our ownSetNotebefore the server's next roster push is not verified. The section comment that saidSetNote"cannot be driven from our frame at all" inferred that from theHasRestrictionsflag alone, which does not name the restriction, and is corrected. Not seen in game: whether the write lands -- a 2019 retail report readsSetNote()as forbidden from a tainted path, which a click would not lift; one save on Era settles it. KNOWN COST: the roster is built once, so the HELD note stays the old one until relog;SetNoteconfirms nothing, so it is not rewritten to claim the write landed. Specs: ten examples inzz_window_spec.lua(the real dialog needs the widget layer): the write asserted insideprotected.asHardwareEventwithprotected.enforceon andwow.blockedempty, nothing written on open or on Cancel, the allow-list per project id, the client's row as starting text, and the cap kept through a window close (a parent hiding is not a close in the widget library) and taken off before the next addon's prompt on the shared frame (F5). The last two went RED with the row read and theonHidereset removed (run20261016-192833.355). - Roster-manager data, asked for by Ultimate Guild Roster (contract
2f8b405d).member.guid-- slot 17 ofGetGuildRosterInfo, read byReadRosterRowin the login build and for a joiner's row. The three Classic trees read it there themselves (FriendsFrame.lua:3390,memberGUID). Stored and handed over, never compared.lib:GetMemberRace(name)->englishRace, localizedRacefromGetPlayerInfoByGUID(member.guid)at call time,nilwhile the client has the character uncached (MayReturnNothing). Not stored: that would be a call per member at login for a field one consumer reads.OnMemberLeft's fifth argumentkickedByandOnMemberRankChanged's fourthchangedBy: the officer the kick / promote / demote message names, through the samePickArgpositional mapping as the member, thenNormalizeName. Trailing, so existing handlers are unaffected. A join's inviter is NOT inERR_GUILD_JOIN_S(one argument) -- it comes from the event log'sinviterow.lib:GetGuildRanks()-> every rank,{ rankIndex (0-based), name }, fromGuildControlGetNumRanks/GuildControlGetRankNameon each call, andOnGuildRanksChanged(ranks)onGUILD_RANKS_UPDATE. It also teachesrankIndexByName, and the promote/demote branch calls it when the new rank name is unknown -- so a promotion into a rank nobody held now movesrankIndexand fires, where it used to update the name and stay silent.lib:RequestGuildEventLog()(QueryGuildEventLog, only when a consumer asks),lib:GetGuildEventLog()andOnGuildEventLogUpdated(log)onGUILD_EVENT_LOG_UPDATE, whoever asked. Each row is{ type, player, actor, rank, ago, time }:player1is the actor for invite / promote / demote / remove (FriendsFrame.lua:2846-2857),timeisGetServerTime()minusagowith 30-day months and 365-day years -- approximate, the server sends nothing finer, and recomputed per parse. Rows are sorted NEWEST FIRST by theagotuple (years, months, days, hours; ties keep index order), because which end ofGetGuildEventInfo's index is newest is not confirmed (Blizzard's reader walksnumEventsdown to 1,FriendsFrame.lua:2838). The callback fires only when type/player/actor/rank/ago changed or when the answer is to aRequestGuildEventLog()call -- Blizzard's log windows re-query on show (FriendsFrame.xml:3938,GuildInfo.xml:436) and on everyGUILD_ROSTER_UPDATEwhile open (FriendsFrame.lua:396), and a consumer that asks later must still get its answer -- though the fresh table is always stored. Forgotten on leaving the guild. (Peer Review, thread d4c39490.)- The rank-name map (
rankIndexByName) is now wiped on leaving the guild and rebuilt from scratch byGetGuildRanks()-- but only from a read that names EVERY rank; a read with an unnamed rank only adds, or ranks still loading would drop out of the map (Ultimate Guild Roster, thread cb69437d). The returned list can be partial;rankIndexsays the slot. Before, a name learned in the OLD guild survived: with 'Officer' at index 1 there and at index 3 in the next guild, a promotion to Officer found the stale 1, skipped the rank-list read, and firedOnMemberRankChangedwith the wrong index. A renamed rank's old name likewise kept its slot.GetHomeGuildInfo()returns nil, not a nameless table, whenGetGuildInfohas no name yet. lib:GetHomeGuildInfo()->{ name, realm, faction, key, clubId }.realmisGetGuildInfo's fourth return with spaces and hyphens stripped, else the player's own (GetRealmName): that return is nil for a same-realm guild per warcraft.wiki.gg, and no Blizzard caller reads it, so the source cannot confirm it.clubIdisC_Club.GetGuildClubId(), which can be nil early (RequiresClubsInitialized).- Declined: public / officer note changes mid-session. Notes are read with the roster once at login, and a mid-session edit waiting for the player's relog is the settled build-once cost (2026-09-16). Done in UGR by re-reading rows, it would cost exactly what build-once removed.
- Specs:
Tests/guildinfo_spec.lua(18), plus the officer argument pinned inchat_spec,roster_specand both ofbuildonce_spec's positional examples (the reordered deDE kick names the kicker though it is written first). The joiner-GUID and empty-rank specs were shown RED with their fixes switched off (run20261015-114931.490). The rank list and the event log come from the harness'sguild.setRanks/guild.setEventLog(delivered inbe92d12, inbox3e695769); the spec's own stand-ins are gone. The harness refuses an event row with no type, so the index the client answers with nothing is now its own example, staged over the harness'sGetGuildEventInfo.
- The
Tests/wowapiharness pin movescc816a0->1d75340. Sinced640d37the harness silences every frame on a library it evicts, which left each earlierenv_e2eclient deaf (8e2e_specfailures, bisected to that commit).env_e2e.clientnow takes a still-live client's instance out of LibStub itself, andenv_e2e.resetsilences every outgoing client's frames the same way the harness does, rather than leaving the ones taken out by hand to the garbage collector (Peer Review, thread44c3e4ce). - Suite 1043 green forward, reverse and shuffled; coverage 5921/5921.
Not covered
- Nothing of this has run in a client. The Communities OnEnter post-hook sits on a row whose OnEnter is Blizzard's mixin method on OUR frame instance; the same rows already carry our replaced OnClick, so no new kind of write to a Blizzard object is made, but taint was not measured in game.
- Whether
GetGuildRosterInforeturns the GUID in slot 17 on retail and WoW: Forever: neither tree's Blizzard code calls it (the Communities roster uses club members), so only the Classic trees confirm the slot. - Whether every flavour's kick and promote/demote lines carry the officer: read from the format strings' argument counts, not from a client.
- The panes' placement was chosen from the windows' layout code and never seen: that the Communities window's bottom strip is free left of Invite Member, that the classic Sisters page's strip is free at (8, 3), that page level + 30 sits above every child the pages build, and that a wheel over the pane stops there. The header height is read from the templates, not seen.
1.2.1 -- NormalizeName follows the regional-names switch
The LibStub MINOR moves 32 -> 33. No API change.
Fixed
NormalizeName's cache now keys onRegionalNames()as well as the realm. It wiped only whenGetRealmName()changed, so a name normalized whileRegionalUniqueNamesEnabled()was true kept its bare spelling after the switch went false, whileGetNormalizedPlayer(uncached) followed the switch -- the two then disagreed for the same character. A flip of the switch now wipes the cache and re-keys the sister rosters exactly as a realm change does (lib.nameCacheRegional).RegionalNames' docstring, which said the answer was "not cached", now says the name cache keys on it. Found by FastGuildInvite (peer review740ceb25): its Forever specs flip the switch, and a later spec file saw a roster row keyedMeagainst a player key ofMe-Testrealm, 27 failures under a shuffled order. On a live client the switch is not expected to change within a session (not verified), so the player-visible impact is believed to be none.- Specs:
normalize_spec"re-spells a cached name when the switch flips, agreeing with GetNormalizedPlayer", andsister_spec"re-keys a fed roster both ways when the regional-names switch flips" -- with the switch on,CanonNamedrops the realm half, so a storedAna-Testrealmre-keys toAnaand back again. The HOME roster is not re-keyed after login (the documented build-once margin), so a mid-session flip, if a live client can make one (not verified), leaves its keys in the old spelling.
Changed
- Removed: the three dev-only boards --
docs/AUDIT.md,docs/LIBRARY_CONTRACTS.mdandTests/HARNESS_CONTRACT.md. Their content was imported into the writ inbox, where every item is closed, and git history keeps the files. None of them shipped (docsandTestsare in.pkgmetaignore:).
1.2.0 -- "ready" and "complete" are separate answers
The LibStub MINOR moves 31 -> 32. Nothing changed shape: three additions, and the server's "No player named X" now reads as known offline (see Changed).
Request from FastGuildInvite (contract a19005f7ee7b, 2026-10-09). FGI judges
a guild-log departure the live route never saw by asking IsReady /
IsInGuild, and IsReady goes true on a SHORT login build: the stabilization
block counts a short scan as complete once retryCount >= MAX_RETRIES, so the
build goes ready on the rows it has. A current member that build never read
then reads as gone. LoginBuildIsShort finishes such a build later, but
nothing told a consumer when -- and it gives up after MAX_LOGIN_REPAIRS (3)
without telling anyone either.
New
lib:IsRosterComplete()-- ready AND the held roster came from a scan that saw as many rows asGetNumGuildMemberscounted. Backed bylib.rosterShort, set from the scan's ownshortwhen the login build goes ready and on every repair: a repair that reads every row clears it, one that comes out short sets it (including the retry path, which has already rewritten the roster with the partial rows). Reset on leaving a guild. A build still short when the repair cap runs out stays incomplete for the session, which is the honest answer.OnRosterCompleted()-- fires on each incomplete -> complete edge: right afterOnRosterReady(and afterOnRosterUpdatedon the same event) when the login build read every row, or when a repair finishes a short build. One handler therefore covers both cases. A repair of a build that was already complete does not fire it again.OnMemberLeft(name, guildKey, kicked)--kickedistruewhen the home message matched the kick patterns,falsefor "has left", and absent for the sister-roster diff, where nothing says why. FGI asked for it as an option so it can drop its guild-log round trip for live departures.lib:GetSisterPresence(guildKey, name)--trueonline,falseknown offline,nilnobody said, for one sister member. Asked for by DeltaSync (contract90f5fd1913f4):GetOnlineMembersScopedcannot tell "known offline" from "nobody said", and DeltaSync's sister-whisper guard declined both. Same sources as that list and no/who("candidates, not presence"). The home guild answers from the roster:isOnlinefor a member,nilfor anyone else.
Changed
The server's "No player named X" now records X as known OFFLINE.
OnPeerUnreachableused to clear the sighting stamp, leaving "nobody said", so a consumer's guard let X straight through again. It now also stampslib.offline[lower(guildKey)][X]in every sister roster holding X, andsisterOnlinereads it newest-wins: a strictly newer sighting or stream "on" beats it, a sighting in the same instant keeps "on" (the clock cannot order them), an aged relay sighting from before it loses. No TTL. Dropped with the guild byRemoveSisterRoster. The guild tab rows and the level readings use the same rule, so they show the member offline too.A repair's retries count as ONE repair. Found by Peer Review (G1, 2026-10-10) before this shipped: a repair that came out short retried like the login build, and every retry event spent one of the
MAX_LOGIN_REPAIRS(3). Three short events spent the cap, so the event that finally carried every row was never read andIsRosterCompletestayed false for the session.lib.repairInProgressnow spans a repair's retries andloginRepairscounts repairs started.lib.repairEventsis the hard bound on events any repair may read (MAX_LOGIN_REPAIRS * (MAX_RETRIES + 1), 18), so a server that never lifts its request throttle cannot keep the library scanning. KNOWN COST: a client that never fills its rows now reads up to 8 times instead of 3 before giving up (one repair's six events plus two more repairs).
Tests
buildonce_spec: seven specs for completeness -- complete login build fires once after ready; short build is ready but incomplete, then a repair completes it and fires once; a repair that comes out short goes incomplete and the next completes it; a short repair's retries count once, so the full event after them completes it (G1); a repair the server never lets retry stops at the event bound; the repair cap running out short never fires; a guild change resets it. The cap spec now asserts reads equal the events read and stop at the bound.chat_specpinskickedfor both messages.livestream_spec: six specs forGetSisterPresence-- nil when nobody said; false after a not-found with no earlier sighting (and absent fromGetOnlineMembersScoped); a newer sighting or stream "on" beats it and an older relayed one does not; no/whoinput; the home guild and bad arguments; dropped with the guild.- 947 specs pass; line coverage 100% on the library and both env layers.
Not covered
- Run in no client. How often a login build actually goes ready short in play is not known here.
1.1.4 -- no more "secret string value" error on WoW: Forever
The LibStub MINOR moves 30 -> 31. No API changed shape.
Field report via FastGuildInvite (peer review 457f6869, Discord, WoW
Forever, count 5): "LibGuildRoster-1.0.lua:2587: attempt to compare local
'message' (a secret string value, while execution tainted by
'GuildRoster')", ParseSystemMessage <- OnChatMsgSystem <- OnEvent.
Fixed
- I gated secret chat payloads on the flavour instead of on the value.
OnChatMsgSystemandOnChatMsgGuildskipped a payload only whenIS_RETAIL(WOW_PROJECT_ID == WOW_PROJECT_MAINLINE) andC_ChatInfo.InChatMessagingLockdown()were both true. WoW: Forever runs the retail client -- itsChatInfoDocumentation.luaflagsCHAT_MSG_SYSTEM,CHAT_MSG_GUILDandCHAT_MSG_OFFICERSecretInChatMessagingLockdownand leavesplayerNamewithoutNeverSecret, and itsFrameScriptDocumentationhasissecretvalue-- but it is notWOW_PROJECT_MAINLINE, so the secret message reachedmessage == "". Both handlers now return whenisSecret(arg)(1.1.3's helper) says the argument is secret, on every flavour. Classic clients haveInChatMessagingLockdownbut never a secret payload, so they keep parsing through a lockdown as before. chat_specmodels a secret payload withissecretvalueon both a Classic and a mainline env, for the system and the guild handler; the old lockdown-flag specs are replaced.- Forever got Classic's 5 s
/whofloor instead of retail's 8 s.IS_RETAILnow also matchesWOW_PROJECT_ID18, which Forever reports: observed in a client on 2026-10-01 and recorded in FastGuildInvite'sFGI_Compatibility.lua(Blizzard's forever tree defines no constant for it). FGI'sfn.getMinScanIntervalalready gives Forever 8 s, so the two libraries now hold their sends apart by the same floor there.WHO_SEND_FLOORis the only thing that readsIS_RETAIL;sistersync_specpins 5 / 8 / 8 for Classic, Forever and Mainline. - 934 passed; coverage 100% on the library,
env_guild.luaandenv_e2e.lua.
Not covered
- Not run on a WoW: Forever client (there is none on this box).
1.1.3 -- no more "secret string value" error on retail
The LibStub MINOR moves 29 -> 30. No API changed shape.
Field report, 2026-09-30, retail: "LibGuildRoster-1.0.lua:1287: attempt to
compare local 'name' (a secret string value, while execution tainted by
'GuildRoster')", from UnitKeyName under OnUnitLevel (UNIT_LEVEL).
Fixed
- 1.1.0's level reading compared unit data that retail can hand back as a
secret value. On retail 12.0+
UnitName,UnitLevel,UnitIsUnitandUnitIsConnectedmay return secrets, and comparing or testing one from addon code is a Lua error. A file-localisSecret(the client'sissecretvalue, absent = nothing is secret) now guards all four:UnitKeyNamereturns nil for a secret name or surname/realm, andOnUnitLeveldrops a unit whose identity, connection or level is secret. The reading is lost for that unit, which is the same outcome as any unit we cannot name. Classic flavours have noissecretvalueand take the old path unchanged. sisterlevel_specmodels a secret as a valueissecretvaluenames and asserts none of the four is read;.luacheckrcand.luarc.jsondeclare the global.
Changed
- Comments only, no behaviour: the GreenWall send helper and
livestream_specnow record that GreenWall 1.14.0'sSendMessagetried a channel send before its relay and raisedADDON_ACTION_BLOCKED52 times on Classic Era on 2026-09-30, and that GreenWall's next release sends API data relay-only with the same signature (peer reviewd49c5edb3f3f), so nothing here changes. - The
Tests/wowapiharness pin moves1ccef14->cc816a0; the whole suite is green on it (930 passed).
Seen in a retail client, 2026-10-01, in a delve: the same line on "target"
printed false Valeera Sanguinar out of combat and true nil in a real
fight, and OnUnitLevel("target") raised nothing either time. Per the
Warcraft Wiki's Secret Values page, a unit's name is secret in combat when
it is neither player-controlled nor in the party or raid -- so the reported
crash was most likely a target or nameplate unit's UNIT_LEVEL.
Not covered
- Which unit was targeted in the in-combat run was not recorded. The secret
UnitLevel/UnitIsUnit/UnitIsConnectedguards were exercised by the spec alone.
1.1.2 -- WoW: Forever whispers go to "First Last", no realm
The LibStub MINOR moves 28 -> 29. No API changed shape; one helper added
(lib:WhisperTarget(name)).
FastGuildInvite contract 54277063, 2026-09-29: on WoW Forever a player saw
three lines of "No player named 'Quebeldorf Flamehammer-ClassicBetaPvE2' is
currently playing." while that player was online and in their guild, and the
reporter "was doing literally nothing". FGI's own whispers were one source; it asked
whether ours were too. Which sends produced those three lines is not known.
Fixed
- Every whisper this library sends addressed the realm-suffixed name, which
the Forever server refuses. 1.1.1 fixed the KEYS on Forever but left the
sends addressing AceComm's raw
sender, which carries the realm. Every WHISPER send now goes throughlib:WhisperTarget(name): the baton'shereanswer to a guildmate'shi, and every sister-pull message (the guild offer, the pull request, the refusal,unchanged,no copy we can vouch for, the delta, the ack and the roster) -- plus the name handed to VersionCheck'sRequestFrom. On a client whereRegionalUniqueNamesEnabled()is true it isCanonName(name), the full name with no realm; everywhere else the name goes through untouched, so no other flavour's addressing changes. That the Forever server accepts the bareFirst Lastis what Blizzard's own whisper UI implies; not verified on a Forever client.
Tests
normalize_spec:WhisperTargetdrops the realm on Forever and leaves a name untouched on every other client.livestream_spec: thehereanswer is addressed exactly as the sender arrived on a normal client (a same-realm sender is bare,Zed), and toZed Stonefistfor a Forever senderZed Stonefist-ClassicBetaPvE2. The sister-pull sites share the same call and are covered by the existing sister-sync and e2e specs on the non-Forever path only.
Docs
- README and the CurseForge page add
lib:WhisperTargetto the API list and a "whisper through it" line to the Forever section, a 1.1.2 section each, and the MINOR moves to 29 in the README header, the feature-detect list anddocs/HEALTHCHECK.md.
1.1.1 -- WoW: Forever names: "First Last", no realm
The LibStub MINOR moves 27 -> 28. No API changed shape; three small
helpers added (lib:RegionalNames(), lib:SurnameSeparator(),
lib:UnitKeyName(unit)).
The user, 2026-09-28: "we may need to look at the forever name normalization, there are no relams, and the characters have a first and last name" / "we probably don't want to break things with normalization".
Fixed
- A Forever character is keyed by its full name, with no realm. On WoW:
Forever a character is "First Last", unique across the region, and realms
are not shown to players (Blizzard, "Create a Name of Your Own in WoW:
Forever").
NormalizeNameappended the viewer's internal realm to every bare name, so one character could key differently on two clients with different internal realms -- the same divergenceCanonNameexists to prevent on connected realms. Now, when the client's ownRegionalUniqueNamesEnabled()says so,NormalizeNamenever appends a realm andCanonNamedrops a-Realmsuffix an API added, so"Drie Stonefist"and"Drie Stonefist-Realm"are one key. Chomp and Total RP 3 made the same call. - The player's own key lost the surname.
GetNormalizedPlayerread onlyUnitName("player")'s first return; on Forever the second return is the SURNAME (Blizzard'sCamelot/NameUtil.luajoins exactly those two), so the player was keyed asFirst-Realm. It is nowFirst Last. - A party member's level reading treated the surname as a realm.
OnUnitLevelbuiltname .. "-" .. secondfromUnitName(unit), which on Forever madeFirst-Last. It goes throughlib:UnitKeyName(unit)now, which joins with the client's surname separator on Forever and with a hyphen everywhere else.
Every other client is untouched: RegionalUniqueNamesEnabled is absent there,
so the switch reads false and every name takes the path it took in 1.1.0.
The separator is read from the engine's
Constants.CharacterNameSeparatorConsts.CHARACTERNAME_SURNAME_SEPARATOR,
with " " when it is absent (Chomp's fallback, and what Blizzard's whisper
parser implies). If a client ever separates the surname with a hyphen,
CanonName keeps the whole string rather than cutting the surname off as a
realm.
Tests
normalize_spec: eight cases for the Forever path -- the switch off by default, no realm appended, a realm suffix dropped, a hyphen separator kept, the player's full name, the missing-separator fallback, a player with no surname, andUnitKeyNameon both kinds of client.env_guild.useRegionalNames(first, surname, sep)models a Forever client, andresetStateclears both globals so nothing leaks into later files. The harness does not model regional names; asked of WoWAPITesting.
Docs
- README and the CurseForge page each gain a "Forever" section, the three
helpers in the API list, and a Forever line in the features list. Both
usage examples now read a unit through
lib:UnitKeyNamerather than bareUnitName, which on Forever returns the surname second and elsewhere drops a cross-realm target's realm when only the first return is kept.
Dev tooling
wow-version-replication.ps1(not shipped): a Deleted whose source file exists again is copied as Changed instead of removed from every replica install, so an editor's save-by-replace cannot delete a live file from the other WoW installs (TOGProfessionMaster's peer review, thread 99d78fe0).
Not covered
- Nothing here has run on a Forever client -- there is no install on this
box. What
GetGuildRosterInfo, chat senders, AceComm senders and/whorows return there is engine-side and was not read from any source; the fix handles both "First Last" and "First Last-Realm" so either is safe. - The separator's actual value is not in Blizzard's Lua source (the constant is engine-supplied). The space is Chomp's fallback, not a reading.
- Sister-guild KEYS (
Guild-Realm) are unchanged; whether Forever's guild names need the same treatment is not known.
1.1.0 -- a live sister-guild presence stream, level-ups, and one shared level refresh
The LibStub MINOR moves 26 -> 27. Two callbacks with one signature, a
request/release pair and the baton's methods added; nothing an addon calls
changed shape. Two new addon-message prefixes, LibGRxlvl
(lib.LEVEL_PREFIX) and LibGRxlive (lib.LIVE_PREFIX), and GreenWall
messages under the addon id GuildRoster.
All seven TOCs now carry ## Category: Library, so the in-game AddOns list
files the library under the "Library" heading with the fleet's other
libraries instead of among the ungrouped addons (VersionCheck-1.0's request,
thread 3ecb0b4bb88f). ## X-Category: Libraries stays for CurseForge. The
title is now in the fleet's brand orange, |cffFF8000Lib: LibGuildRoster|r,
on every flavour (DeltaSync thread 3f323604089b); toc_spec pins both.
Added
THE BATON: the home guild's live presence, carried to the sister guilds. The user, 2026-09-27: "deltasync requires near real time liveness and you do this well, for your host guild. but with cross guild syncs, this is an issue we'll want to tackle", and on the shape, "it would have to be airtight and FAST".
- Who holds it. The online library user in the guild whose name sorts
first (
IsBatonHolder). Every client works it out from the same facts, so agreeing costs no messages.- At roster-ready,
StartLivesendshion GUILD. - Each online library user who sorts before the newcomer whispers
here. They are the only ones who could outrank it. - A ding or zone message on GUILD also proves a guildmate runs the library.
- At roster-ready,
- Handover. It needs no message and no timer. The holder's logoff is the server's own "has gone offline" message, which every client hears at once. They drop the holder from the peer list, and the next name holds the baton. That client's first act is to forward the old holder's logoff.
- What the holder sends over GreenWall (
ForwardLive):on:<name>/off:<name>for each home logon and logoff.lvl:<level>:<name>for a ding by a guildmate who runs no library. One who runs it sends their own ding. A client holding a level refresh sends these too, holder or not -- only such a client reads those dings, so tying them to the baton lost every one whenever the holder ran no consumer that asked for the refresh (Peer Review, thread aabe287416b7, finding D). A ding sent twice changes nothing: the held level only rises, andlivestream_specpins that a repeat fires no second callback. The cost: one ding is one GreenWall message per online client holding a level refresh (each TOGTools user), not one per guild.snap:<id>:<i>/<n>:<names>chunks of who is online (SendLiveSnapshot,SNAP_CHUNKcharacters each). These answer thewantthat every client sends once when it comes up.- A name on the sender's own realm goes bare (
WireName), and the receiver puts the SENDER's realm back on it, not its own. Peer Review asked for it (same thread): a full name can fill a snapshot chunk alone.
- The receiving side (
OnGreenWallMessage):- It places the sender in a listed sister guild, and every member a message names must be in that same roster.
- It records
on/offinlib.live, and a complete snapshot marks everyone it omits offline, except its sender. - It fires
OnSisterLocationsUpdated. GetOnlineMembersScopednow counts a streamonwhile that guild's stream has spoken withinLIVE_STREAM_TTL. A streamoffclears the member's sighting, so a later sighting still wins.- The server's "No player named X" reply (
OnPeerUnreachable) now also overrides a streamon. - The guild tab rows let a newer stream entry beat an older
/whosighting.
- Known gap, stated plainly. When a guild's last library user logs off,
nobody sends their
off, or anyone's after it. The receiver's copy of that stream stops counting afterLIVE_STREAM_TTLand falls back to sightings. It never falls back to a stale "online". - Also asked of DeltaSync (contract 69fba6d764ec): guard directed sends to
sister members with
IsInAnyRoster+GetOnlineMembersScoped. Its guard reads only the homeGetMembertoday.
- Who holds it. The online library user in the guild whose name sorts
first (
Library users report their own zone (
OnZoneChanged). OnZONE_CHANGED_NEW_AREAthe client sends onezone:<text>message on GUILD, and every other client sets that member's zone. The server has no message for a guildmate changing zone. The level refresh (below) now also refreshes an ONLINE member's zone from the rows it already reads. An offline row's zone is the login build's to set.OnHomeMemberLevelChanged(charKey, guildKey, oldLevel, newLevel, source), the home guild's half, asked for by TOGTools (contract 9c178c96f673) so Gratz can drop its 60-secondC_GuildInfo.GuildRosterpoll. The operator: "this is the opportunity to have GR get lvl 'live' like it does for presence ... if the GR could get that info for free like it does presence, we wouldn't have to do expensive lookups and compares". What is free, looked for across the per-flavour trees and the web: no server message announces another player's level on any flavour;PLAYER_LEVEL_UPcarries no unit and is the player's own;UNIT_LEVELnames a unit token, so it covers group and target units; the "Level N" guild-achievement broadcast exists only where achievements do (never Era), every tenth level, and was not confirmed to announce -- not used. So there are two sources."comm": every library user sends their own ding on GUILD (OnPlayerLevelUp, one short message per level) and every other library user'sOnLevelCommtakes it from a home member -- proof of a live ding, and proof of presence the same as guild chat."unit":UNIT_LEVELfor a grouped home member (OnUnitLevel), which is a ding only when their held level was read while they were online and they have not come online since (levelStale, set inMarkMemberOnline). The held level ismember.level, soGetMemberstays current and a ding seen by both sources fires once. A mid-session joiner's first level is a baseline (levelUnknown). The player's own ding updates their record and fires nothing. Nothing polls and nothing re-reads the roster; build-once stands. A guildmate without the library, outside the player's group, is not seen.UNIT_LEVELfor a grouped sister member is an online reading for the sister callback below (source"unit").A player's own ding also goes to the sister guilds, over GreenWall (TOGTools contract 58cbd95c507a). Seen in game on 2026-09-27: a sister member dinged 40 and no callback fired, because no
/whoor pull had read their level before the ding, so the first reading after it could only be a baseline.OnPlayerLevelUpnow also sendslvl:<level>withGreenWallAPI.SendMessage("GuildRoster", ...)while a sister guild is listed, when GreenWall's API is version 2 (which sends with no key press, GreenWallAPI.lua:37-38) andC_AddOnsknows this library's id (GreenWall asserts it,API.lua:59; an embedded copy with no GuildRoster folder skips).OnGreenWallLevelplaces the sender by the sister rosters we hold (GreenWall's handler says only whether a message is from our own guild), marks them online, and firesOnSisterMemberLevelChangedsource"comm"-- proof of a live ding, so it needs no earlier online reading and, for a member no reading has placed a level on, fires witholdLevelnil. A sender we cannot place, or in an unlisted guild, is dropped. Not changed: a/whosighting still does not treat a roster copy's level as the "before" reading -- that copy does not say the member was online, so a higher/wholevel could be a ding from hours earlier.lib:RequestLevelRefresh(owner)/lib:ReleaseLevelRefresh(owner), the one exception to build-once (TOGTools contract 7e447fb6d5c7). The operator, 2026-09-27: "ok, then we should probably move the 60s recheck into guildroster, shouldn't we". While any consumer holds it, aGUILD_ROSTER_UPDATEafter ready re-reads the rows' LEVELS (ReadRosterLevels, source"roster") when it answers our request or whenLEVEL_REFRESH_INTERVAL(60 s) has passed since the last read, and asks the server again (OnLevelRefreshEvent) at most once per interval, only when the event'scanRequestRosterUpdatesays it may, and never on the event whose login verification just asked. One request per client whoever holds it. No timer: the first cut ran the request from aC_Timer.NewTickerand was refused, because the operator's rule is no timer that touches the WoW API; so the cadence follows the guild's own events, and a quiet guild refreshes less often than every 60 s. Levels only -- membership, presence, rank and notes stay with their chat events; the show-offline setting is never written; with no holder the event is still ignored and reads no row (buildonce_specunchanged). A "roster" reading fires only for a member our presence model has online and who has not come online since the held level was read; a row with no level is skipped, never a baseline; the held level never drops, so a cache behind a"comm"ding does not fire it twice.OnSisterMemberLevelChanged(charKey, guildKey, oldLevel, newLevel, source), asked for by TOGTools (contract 2f198123af10) so Gratz can congratulate a sister-guild ding. The operator: "the user though i should detect level ups in the SISTER guild and grats members in the SISTER guild, not just the guild i'm in". Every/whorow about a sister member (recordLocation, source"who") and every member of aSetSisterRosterlanding (source"roster") is a reading, filed in the new in-memorylib.sisterLevels[lower(guildKey)][charKey] = { level, online }bynoteSisterLevel. It fires only when the level is higher than the one held AND the member was known online at the last reading and this one. A/whorow is online by definition; a roster reading is online when presence (MarkOnline) or a/whorow saw them withinPRESENCE_TTL(knownOnline), and otherwise says nothing about presence rather than "offline" -- a roster's level for somebody offline is last-known and must never read as a live ding. A clean location answer that did not list a member (TakeLocateAnswer) marks them offline. The first reading in a session and a member who just joined are baselines; the held level only rises, so one ding seen by a/whoand then a pull fires once; the home guild is never recorded.guildKeyis the listed spelling. Readings are forgotten with the guild (RemoveSisterRoster, an unlisting).Tests/sisterlevel_spec.lua, 33 specs across both callbacks, the refresh and the GreenWall forwarding;Tests/livestream_spec.lua, 25 specs for the baton, the stream and zones. Both against a stub GreenWall; the harness has no GreenWall model. Writing them found one defect before it shipped: a complete snapshot that did not name its own sender marked the sender offline, though the message itself proves them online.One rule for "is this sister member online" (
sisterOnline), from the session's self-audit. It had been three separate rules:GetOnlineMembersScoped, the guild tab rows'/whooverride, andknownOnline. Now the newest statement wins among a sighting, the stream and (for rows and level readings only) a/whorow.GetOnlineMembersScopedstill leaves/whorows out on purpose, because a/whorow says the member is online, not that they can answer (the candidates rule). The same audit found two gaps, both fixed:- The server's "No player named X" reply did not override an older
/who"online" (OnPeerUnreachablenow marks the/whorecord offline). gwSendcounted a message GreenWall refused (ok = false) as sent.
- The server's "No player named X" reply did not override an older
Not covered
- Not run in a client. The addon-channel announcement,
UNIT_LEVEL, the level refresh, the baton and zone reports are exercised only against the harness. How often a real guild's events let the refresh run is not measured. SNAP_CHUNKwas 120 and every chunk would have been refused. Peer Review (thread aabe287416b7, finding A) pointed out that GreenWall's relay refuses, rather than cuts, a payload over 255 minus its header (Relay.lua:236-243). It is now 64.livestream_specruns our real sends through GreenWall's realRelay.lua,Constants.luaandBase64BCA.luafrom a worst-case sender, and asserts each one is accepted. The worst-case sender has a 12-letter name on a 24-letter realm and a 12-character guild id; that guild-id length is an assumption. At that worst case (members on another realm, sent in full) a chunk holds one name, so 31 online members make 30 GreenWall messages per snapshot. Same-realm names go bare, and 31 ten-letter ones make 6.- Sister readings come only as often as
/whoanswers and pulls (five minutes apart per guild), so a sister ding is seen minutes late, or missed when the member logs off in between -- accepted by the operator ("it would be slower") -- for sister members who do not run the library; those who do announce their own ding over GreenWall (above). The GreenWall send fromPLAYER_LEVEL_UPis not verified in a client; GreenWall's own API.lua:37-38 says API version 2 needs no key press.
1.0.1 -- the guild tab is called Members, and three retail fixes
The LibStub MINOR moves 25 -> 26. Nothing an addon calls changed shape.
Added on lib: the constant GUILD_TAB_NAME, and BuildGuildTabInvite /
SyncGuildTabInvite (internal to the tab; listed because they are on lib).
Changed
- The tab is "Members" on both windows (
lib.GUILD_TAB_NAME; the Communities tab's tooltip, the classic bottom tab's text, the "i" help's title and the/grcheckbox label all read it). It was "Sisters" / "Guild and sister guilds". Players without a sister guild -- most of them -- read that as "not for you" and asked for it gone. The user, 2026-09-27: "it needs to not have the sister guild theme, focus on it's just a better guild tab than the game gives you." The "i" help (GUILD_TAB_HELP) now leads with the search filter and Reset button, the two things it adds over the game's own roster (sortable columns are not mentioned: the game's tab has them too), puts sister guilds in one optional paragraph, and ends with how to turn the tab off in/gr. The/guildroster tabstatus line and the diagnostics are unchanged.
Fixed
- Retail: no bottom border under the Chat-box patch. After switching from
the Chat tab, the strip that covers the Chat page's input box stopped at the
window's
Bg, which on retail ends 2 above the window's bottom (MainlineSharedUIPanelTemplates.xml:603), and retail has noBottomBorder-- its frame is a NineSlice child (:550). The strip ended short with no border under it. On a window with noBottomBorderand aNineSlice.BottomEdge, the strip now runs to the bottom of that edge piece andrec.footEdgeredraws it over our stone: same atlas (or texture and coords), same tiling,SetAllPointson theirs. Clients with aBottomBorderare unchanged. Two specs inrostersisters_spec.lua(atlas and texture edges). The user confirmed the border in game on 2026-09-27. - The Chat page's control button showed through over the "i". The strip
ended 10 past the chat box, and the control-frame button beside Invite Member
(
CommunitiesFrame.xml:583) poked out between it and Invite Member, over our "i". The strip's right edge is now Invite Member's LEFT (the old box-plus-margin edge only on a window without that button), andLiftGuildTabPageliftsrec.infoabove the strip on every show. The user confirmed it in game on retail on 2026-09-27. - Retail: no Invite Member when the tab was opened from Roster. Blizzard's
InviteButtonbelongs to the Chat display mode only (CommunitiesFrame.lua:544-563), so over the Roster page it is hidden and our tab showed no Invite Member at all. We may not Show theirs, soBuildGuildTabInvitemakes one of ours on their rectangle that calls what theirs calls (CommunitiesUtil.OpenInviteDialog(clubId, streamId),CommunitiesInvitationFrame.lua:111-118-- the game's own Add Guild Member popup for a guild), andSyncGuildTabInviteshows it on everyShowGuildTabonly while theirs is hidden, enabled byCanGuildInvite()as theirs is. Not built on a client withoutCommunitiesUtil. The user confirmed it in game on retail on 2026-09-27.
1.0.0 -- where sister members are, from /who, and the /gr list draws again
Released as 1.0.0 rather than 0.10.0 -- the user, 2026-09-26: "lets go ahead and make this v1.0.0". Nothing about the number changes behaviour.
The LibStub MINOR moves 24 -> 25. The user, 2026-09-26: "add the ability to somehow click on an sister guild member and have it do a /who query to update where they are, following /who rules in FGI ... we could have it do a /who <sister guild name> and get 50 members, and some way to do the leaf split like FGI", and "especially automating it so people don't have to push the /who button ... we just need to ensure they don't conflict."
Added
- Sister members' zone, level and online state come from
/who.lib.locations[lower(guildKey)][charKey] = { zone, level, class, online, at }is the server's last word on each member. EVERYWHO_LIST_UPDATEfeeds it -- ours, the player's own, another addon's -- because every row is a free sighting. A COMPLETE answer that covered a member and did not list them writesonline = false. It is deliberately notlib.presence: presence picks who to pull a roster from and means "heard on the wire, runs the library"; a/whorow says only "online".BuildSisterRosterRowstakes the NEWER of the two for online (each good forPRESENCE_TTL), the/whozone while it still says online, and the/wholevel over the roster copy's. The Communities tab's Zone column now fills for sister members; the classic Sisters page shows the zone in place of the guild name once one is known. New callbackOnSisterLocationsUpdated(lowerGuildKey); both tabs repaint on it. - Automatic, from a click the player was making anyway. Opening either
guild tab (
ShowGuildTab,ShowClassicGuildTab) callslib:RefreshSisterLocations(force, onlyKey), which owes one/whoper listed guild, paced byLOCATE_INTERVAL(=WHO_INTERVAL). The existing click overlay sends it on the player's next click anywhere -- no button. - The leaf split, FGI's rules. A location answer cut at the server's fifty
(
total > n) is asked again in pieces bylib:SplitLocate(spec, levels): level band first, cut by a port of FGI'sfn.levelSplitPoint(functions.lua:6213-6229-- the upper median when one level holds half the rows, else the middle), then a single capped level by class using the client's ownWHO_TAG_CLASSand localised class names, limited to the classes the held copy has at that level. Bounded byLOCATE_LEAF_LIMIT(16) split queries per refresh; past it, or with nothing left to split on, the fifty rows are kept and the event ring says so. Absence from a PARTIAL answer proves nothing and marks nobody offline. - "Where is X? (/who)" and "Who is online in <guild>? (/who)" on a sister
row's right-click menu (
GuildTabRowMenuItems(name, row)-- the second argument is new; sister rows now carrysisterKey).lib:LocateMember(name, key)queues a/whoby bare name as URGENT and sends it inside the menu's own click when the floor allows, else first on the next click. lib:IsWhoOwed()-- true while a SYNC/whoof ours would go out on the next click (pastWHO_SEND_FLOOR, the Who list not the player's, not held by the guild step). The public form of what FGI's Wingman reads to yield its click (it readwhoSync.queuedirectly; threadc5a1a43e). Location queries are deliberately not counted. The guild-step half isheldByGuildStep, a read-only mirror ofwaitingOnGuildsharing the guildmate search (guildmateToAsk, split out ofPullFromGuildmates).lib:QueueLocate,lib:TakeLocateAnswer,lib:WhoPaneIsPlayersare public for the specs.- A Reset button beside each guild tab's search box. The user,
2026-09-26: "if i filter on a column, how do i get it back ot the 'default'
view ... without doing a /reload? if there isn't a way, we need a small reset
button somewhere in the strip in the top" -- there was no way: a header
click only ever moved the sort to another column or reversed it
(
SortByColumnIndex,SortClassicGuildTab).lib:ResetGuildTabView(rec)clears the sort, the direction and the search, and repaints: online first, then by name. Show Offline Members is left alone -- a saved setting with its own checkbox in the same strip. Each search box gave up the button's 64 px (GUILD_TAB_SEARCH_WIDTH300 -> 236,CLASSIC_TAB_SEARCH_WIDTH316 -> 252) so the strip ends where it did; whether the placeholder still fits on one line at 236 was checked in game on 2026-09-26: it does (it wrapped at 180).
Changed
- Location queries have their OWN queue,
whoSync.locateQueue, and are low priority. FGI v2.14.1 yields Wingman's/whostep wheneverwhoSync.queueis non-empty (fn.foreignWhoOwed), so a sixteen-leaf split there would take every Wingman click for over a minute. Order on a click: a member the player asked about, then the sync's queries, then location queries -- and those ALTERNATE with any other sender: the SendWho hook now files each send as ours (lastOursAt, flagged bywhoSync.sendingOursaround our own call) or foreign (lastForeignAt), and while a foreign sender was active withinLOCATE_FOREIGN_WINDOW(60 s) and the last send was ours, the location query waits a click. Stamps read at click time; no timer. The same text is never sent twice:QueueWhoabsorbs a waiting location query of the same text and the spec rides the sync's answer, andkeepLocatemoves a spec'd query to the location queue when the sync drops it (pruneWho,FinishGuildStep). - "The Who list is the player's" replaces "the Friends frame is open".
SendQueuedWhoheld every/whowhileFriendsFramewas shown -- which includes our own classic Sisters page, so a player looking at it could never be sent one. It now holds only while the Who tab is selected (FRIEND_TAB_WHO, ClassicFriendsFrame.lua:53): our registration is borrowed while a query is out, so the answer cannot reachWhoList_Update, the function that switches the frame to the Who tab (FriendsFrame.lua:1003). A client without the tab helpers keeps the old, wider rule. The status bar and event now say "the Who list" rather than "the Social window". - The registration debt settles when the WHO TAB shows, not when the Social
window opens --
HookWhoPanehooksWhoFrame's OnShow (the sub-frameFriendsFrame_ShowSubFrame("WhoFrame")shows, ClassicFriendsFrame.lua:479), falling back to FriendsFrame on a client without it. Opening Social on the Guild tab or our Sisters page used to hand Blizzard's handler back while another addon's/whocould still be out, and that answer would switch the window to the Who tab. Mirrors FGI v2.14.6'swhoPaneOf(threadc5a1a43e). - A guild dropped from the list also forgets its locations and any location
query waiting for it (
forgetSyncState). - An answer that is plainly somebody else's is no longer consumed as ours.
Our guild
/whoanswered with rows and none of them in that guild (FGI's level band, the player's own/who) is passed over:outstandingand theaskedFIFO stay as they were, so our real answer lands in its own slot instead of being read into whatever we send next. Peer Review's partial remedy (threadc5a1a43e, audit7033364662e7). Not airtight: an empty answer fits every question and is still taken, and because our plain-name query can match only non-members, one query is passed over at most once (whoSync.skippedHead) -- a wrong skip costs one answer, never the queue. Alongside it, a guild-wide answer now REPLACES the candidates of only the guild it asked about; rows for another sister guild in it merge. - Absence is taken only from a CLEAN location answer. A complete answer to
a guild or band query marks members offline only when every row in it is
that guild's. Found from the operator's in-game screen on 2026-09-26: sister
members heard on the wire (VersionCheck) showed online with a blank zone,
meaning an older
/whorecord had marked them offline -- and the only check before that was "some row is the guild's", which an FGI level-band answer read in our slot passes (a few of the guild's 60s among strangers). Whether that answer was the cause in that session is not verified; the events had scrolled out of the ring. A mixed answer's rows are still recorded as sightings. - A location
/whosent and never answered no longer blocks every later refresh.whoPendingcounted a text in theaskedFIFO as "on its way", so a refresh only filed its spec on it -- and an answer the server ate never came, so for the rest of the session no location/whowent out. Seen in game 2026-09-26: sister members showed online (from wire sightings) with blank zones, the event ring had no "asked the server" line, and the player's own/whofilled them. Now only a text still WAITING to be sent is pending; a resend costs one/who. - Absence needs an UNAMBIGUOUS answer, and a lost query is dropped at our
next send -- Peer Review's remedy for self-audit finding F1 (thread
7059e8cf). A lost answer shifted theaskedFIFO, so the answer to "Where is Cid?" could be read as the whole-guild query's and mark every other member offline. Now (a) absence is written only when exactly one query of ours was out and no other/whowas sent at or after it (the SendWho hook's stamps), else the rows are sightings only; and (b) at our next send -- a click -- any of our queries unanswered pastWHO_SEND_FLOORis counted lost and dropped (whoSync.askedAt), so two of ours are never out at once. KNOWN COST: a guild answer slower than the floor now lands in the next query's slot. - An "i" beside Invite Member on the Communities tab, with how the tab
works (
lib:BuildGuildTabInfo,GUILD_TAB_HELP): the same icon as LibAceGUIWidgets' ClearFrame info button, tooltip through itsAttachTooltip, parented to our page so it shows only with it. The user, 2026-09-26: "we need to add our famous i icon next to the invite member with the tooltips on how thit tab works" -- seen in game the same day. It breathes (W:Breathe, the fleet's pulse from TOGBankClassic's help icon) until the player hovers it once; remembered account-wide asLibGuildRosterDB.guildTabHelpSeen. "can we make it breath the first time someone opens the tab". - A paced refresh says so: "where sister members are: asked Ns ago, next /who in Ns (or right-click a member)", instead of doing nothing silently.
- The
/grwindow keeps itself current. Its list and its Diagnostics block repainted only on six library callbacks, none of which a sighting, a/whoanswer or a new sync event fires -- the user, 2026-09-26: "you need to make the GR UI refresh, it currently doesn't, i have to open/close/open it to see new stuff". The 1 s display ticker (already running while the window is open, retired when it closes) now repaints both when their content changed, and re-lays-out only then. It also listens toOnSisterLocationsUpdated. - The /who section header's "the ONE deferred call" sentence was stale since 0.9.2 and now says nothing there is deferred.
Fixed
- The
/grwindow's guild list drew no rows. Its host is an AceGUISimpleGroup, whoseLayoutFinishedsets its height to its AceGUI children's (AceGUIContainer-SimpleGroup.lua:27-30) -- and the list is a raw frame, so every layout pass wrote 0 over the 130. Harmless until LibAceGUIWidgets v0.2.4, whose RowList sizes itself from its parent: data, no rows, and a chat line from the widget library saying so. Measured offline by tracing theSetHeight(0)calls; fixed with AceGUI's ownnoAutoHeight. My defect since the window was built. Not seen in game. - WoW Forever: a sister-guild
/whoopened the Group Finder on its Who tab. Found by FastGuildInvite (peer review1a5ce19f) in theforevertree: the Group Finder'sLFGWhoListFrameregistersWHO_LIST_UPDATEin its OnLoad and, whileC_SocialUI.IsSystemEnabled()is false, answers withShowUIPanel(LFGParentFrame)(Blizzard_GroupFinder_VanillaStyle/Mainline/WhoList.lua:165,:236-239). The library held onlyFriendsFrame, so the Forever pane got every answer. My defect since 0.9.2 shipped a Forever TOC. Now every Blizzard Who host inlib.WHO_PANE_FRAMESthat exists is held (BorrowWhoPanes), the ones held are remembered inwhoSync.heldPanes, andGiveBackWhoListhands back exactly those. The Group Finder is load-on-demand, so a load under a standing debt is taken atADDON_LOADED(OnWhoPaneLoaded), and an answer that finds it listening takes it back as it already did for a third party's hand-back. The player opening that Who list settles the debt (an OnShow post-hook), and it being on screen holds our/who(WhoPaneIsPlayers). Named frames rather than FGI'sGetFramesRegisteredForEventwalk: the two hosts are known from the source, and the walk needs a harness model not yet delivered (FGI's contractf9c248f0). Not seen in game; there is no Forever client on this box.
Docs
- README and the CurseForge page brought level with the library. The
CurseForge page's Compatibility list had never gained World of Warcraft:
Forever (0.9.2); its Callbacks section lacked
OnMailAutocompleteChanged,OnGuildTabChangedandOnSisterLocationsUpdated; its MINOR list stopped at 0.9.2; and it named neitherGuildTabRowMenuItems,ResetGuildTabViewnor the Who-host methods. The README's Callbacks section lackedOnSisterLocationsUpdated, and its/whoparagraph named onlyFriendsFrame. Both now say thatOnSisterLocationsUpdatedcan fire with no guild key (the/whoanswer handler's finalFire).
Testing
- Harness pin
bb80c1b->1ccef14, taking0a1a896(contractf275ebec: a hollowwow.reset()evicts the widget layer's globals, so the two hand-written scroll-plumbing nils inTests/env_guild.luaare gone). Four search specs went red on it; every one was the env, not the library:Tests/env_guild.luastill stood in forC_Club.GetGuildClubId, the two Club enums andC_CreatureInfo.GetClassInfo-- all the harness's own since6770e26, which I asked for (inbox8e9aa2eb) and never adopted. The stand-in answered club id 7;frames.reset()now reinstalls the harness'sGetGuildClubId(id 1), so the member list answered an unknown club and the guildmate half of every search came back empty. Deleted; the member list is keyed onwow.guildClubId, read at call time.- The class column now reads the harness's DISPLAY name (
Warrior), as the client's does. The stand-in returned the token, and two classic-tab assertions had pinned that wrong value. ResetGuildTabViewcleared the query beforeSetText(""), whoseOnTextChangedthen wrote""back. Nothing drew differently (empty is no filter), but the state was not the "no query" the function promises; thenilis now set after the text.- The hollow scroll stand-ins are installed on every reset instead of only
where absent: the harness records a name as widget-owned only if nothing
held it before the widget layer's first install, so our stand-in made
those two names permanently unowned, the widget layer's copies survived
every later hollow reset, and the stand-in's body never ran (coverage
99.13%). Reported to WoWAPITesting (
dc070b16), fixed there as1ccef14, which this pin takes; the always-install stays, and is correct either way.
Tests/cfhtml_spec.lua, new: the CurseForge description goes through the harness's owncfhtml.lua(required as a module) on every run -- well-formed, under the 90,000-character ceiling, Discord the only link. The page had no checker at all until now. The desk has no route to that checker (filed with writ as8bf857eb), so it lives in the suite instead.- The suite is order-independent now.
run.lua --order reversewas red (5), and both causes were mine.zz_window_spec.lualoads LibAceGUIWidgets and nothing let it go, so every later spec saw a widget library the plain env lacks:/guildrosteropened the window instead of printing usage, and widget code ran on the hollow layer.env_guild's reset now forgets it, beside DeltaSync and VersionCheck. Andsister_spec's exact-PRESENCE_TTLboundary misjudged from a fractional clock (the harness clock never resets, and(t + 900) - tis not exactly 900 in floating point); it now starts on a whole second. At pin1ccef14: default,--order reverseand--order shuffle:1790463113are all 855/0, coverage 100% (4989/4989).
Not verified
- Seen in game 2026-09-26 (Classic Era, Communities tab): sister members
(The Other Gods) show their
/whozone in the Zone column and in the hover card, beside the guild name in the Rank column. The/grwindow's list draws its row again (The Other Gods, 288 members, 7 online, held), and a location/whowent out from a click and was answered with 8 rows -- under the cap, so no split ran. The Reset button works, and the search placeholder still fits on one line at 236. After thewhoPendingfix, the library's own location/whowent out from a click and filled the sister zones with no manual/who, the/grwindow showing it as it happened. Nothing below that line has been checked in a client yet. In particular the level-band form<guild name> <lo>-<hi>is unproven: the plain guild name is the form with evidence (0.8.0), and whether the server honours a band beside a multi-word plain name is not known. If it ignores the band every leaf returns the same fifty, the split walks to its 16-query limit and stops. WHO_TAG_CLASSis read at call time, and the class axis simply does not split on a client without it. Correction from Peer Review (thread7059e8cf): it IS on this box --F:\Blizzard API Docs\GlobalStrings\enUS.lua:23749,"c-"-- which flavour that folder describes is not verified.- The level-band split has never run in a client: in the operator's session
the only listed sister guild, The Other Gods, had 8 online (their
/grscreenshot, 2026-09-26), under the 50 that triggers it. - An answer that is somebody else's, read in our slot, is judged no further when it lists nobody of the asked guild or a guild row outside the asked band; an EMPTY foreign answer still reads as "nobody online" for what we asked (nobody can tell whose an empty answer is).
0.9.2 -- the Social pane stays shut beside Wingman, and a WoW Forever TOC
The LibStub MINOR moves 23 -> 24. Both items came in on the inbox from
FastGuildInvite's session on 2026-09-20: a peer-review finding (c5a1a43e)
and a contract (d1bf13d8).
Fixed
- The Social pane popped open during Wingman use when both addons had a
/whoout from the same click. Two field reports the same day (the user on retail, rocky on Discord: "when wingman is enabled, /who pops up automatically"). Blizzard'sFriendsFrameis the only frame registered forWHO_LIST_UPDATE, and its handler ends inWhoList_Update->ShowUIPanel(FriendsFrame). Both libraries borrow that registration off while a background/whois out, and each handed it back when ITS OWN answer arrived -- mine one frame after the event (RestoreFriendsFrame). Nobody can tell whose answer aWHO_LIST_UPDATEis, so with one query of ours and one of FGI's out at once, whichever answer landed first re-registered the frame while the other query was still out, and the second answer reached Blizzard's handler alone. My half of the race; FGI's own half (a written-off query's late answer) is fixed in its v2.14.1. - The registration now goes back when the PLAYER wants the pane, never on
an answer -- the rule FGI v2.14.1 adopted, mirrored here so the hole is
closed from both sides. The debt (
whoSync.borrowed) stands across as many queries as it takes;lib:GiveBackWhoList()settles it on exactly two occasions, from two post-hookslib:HookWhoPane()installs at the first borrow: FriendsFrame's OnShow (opening the pane repaints the Who list from the client's buffer by itself --FriendsFrame_OnShow->FriendsFrame_Update->WhoList_Update, ClassicFriendsFrame.lua:442,:480), andC_FriendList.SendWhowith a non-nilorigin: every Blizzard call site passes anEnum.SocialWhoOrigin(SlashCommands.lua:1111Chat,FriendsFrame.lua:1233Social,ItemRefHandlers.lua:50Item, in all four flavour trees; the argument isNilable = trueinFriendListDocumentation.lua) and no addon does, so an addon-origin send is one that borrowed the registration itself and is left alone. Both hooks are keyed to the objects, not booleans, because the suite reinstallsFriendsFrameandC_FriendListbetween spec files. - The send borrows unconditionally rather than only while
borrowedis false, and an answer that finds Blizzard listening under a standing debt takes the registration back for whatever is still out -- both for the case where a third party (an FGI older than v2.14.1) re-registered the frame under us. - The "close the pane our answer opened" step is gone with the
hand-back, and it had a defect of its own: a pane the player opened while
our query was out was "up but not at the send", so their own Who list was
shut in their face. Its OnShow now settles the debt first, and the answer
reads the settled debt as "the player wanted this". This also removes the
one
C_Timer.After(0, ...)the/whopath had. - KNOWN COST, stated rather than hidden: an FGI older than v2.14.1 alongside this release still hands the registration back on its own answer, and nothing here closes the pane that lets our answer open. That fix is FGI's, in the same day's release.
- A
/whois held inside the server's floor of the last/whoANYONE sent (WHO_SEND_FLOOR: 8 s on retail, 5 s elsewhere -- the operator's own measurements recorded in FGI'sfn.getMinScanInterval: "on my classic pretty empty server, it's at about 4s that it doesn't eat a /who. we know on retail we need 8s as a minimum"). FGI's reply on the same thread named the hole that remains once the pane stays shut: neither library can tell whoseWHO_LIST_UPDATEit is, so with a query of ours and one of FGI's out together the first answer is consumed by BOTH -- ours reads a level band's fifty strangers as the asked guild's answer ("nobody online" for the round), FGI credits its band as searched against our guild rows. FGI's half already refuses a send inside the floor measured from any send its hook saw, ours included; this is our half. TheSendWhopost-hook stamps every send whatever its origin,SendQueuedWhostamps its own as well (a client the hook could not be installed on), and a click inside the floor is held with the queue intact and the status bar saying how long -- the next click past it sends. The hooks are installed atPLAYER_LOGINas well as at the first borrow for this: FGI's Wingman and our overlay fire on the SAME click, so its send precedes our first borrow, and a send never seen never stamps the floor -- the server would have eaten our first query. A click-time read of a stamp, exactly likelastWho; nothing waits on it. The two libraries can now only overlap when an answer takes longer than the floor. Two of OUR OWN queries cannot go out inside it either, which the server was already refusing silently.
New
GuildRoster_Camelot.toc(## Interface: 16001), the_Mainlinefile list verbatim, for World of Warcraft: Forever (Battle.net productwow_classic_beta, codename Camelot): the retail client -- Menu API,C_*namespaces,Blizzard_FriendsFrame/Mainline,Blizzard_Communities-- running classic-era content. The client reads<Addon>_Camelot.tocahead of_Mainline.toc(warcraft.wiki.gg, TOC format: standard, mists, cata, wrath, tbc, camelot, vanilla, ..., mainline, classic, then the plain file). FastGuildInvite v2.14.0 ships its own_CamelotTOC and hard-depends on this library, so without ours it did not load there.WOW_PROJECT_IDisWOW_PROJECT_MAINLINEon Forever (no constant of its own), soIS_RETAILtakes the retail path everywhere, which is right: the Communities tab's install is the one Forever takes, and the classic tab's template check from 0.9.1 -- whose first field report came from a Forever client -- keeps the classic tab off. WhatGetNormalizedRealmNameand/whorows return on a server with no realm names is unpublished and untouched.
Docs
- README: the
/whoparagraph names the new rule and the two public methods; the dependency table has the seventh row. The CurseForge page's dependency table has it too, and its Recent Updates keeps the last five releases -- the v0.8.0 block moved to the full changelog.CLAUDE.md: the/whobullet carries the rule and the known cost, the TOC sync rule names seven files, and the no-timers bullet no longer claims a deferred hand-back exists. - The 0.8.0 section of this file (727 lines, 49,369 characters) moved whole
to
CHANGELOG_ARCHIVE.md, at 11,552 characters of headroom under the release-body ceiling -- about two release blocks on this project.
Testing
sistersync_spec: the answer test now asserts the debt STANDS; a new test drives the three settlement cases (OnShow settles it; aSendWhowith an origin settles it before the answer; an addon's send -- ours included -- settles nothing) and that each hook is installed once per object; a new test plays a third party re-registering the frame under us; the old closer test is rewritten as the case it got wrong (a pane the player opened mid-query is left alone, and the next/whois theirs); the degrade test's "withoutC_Timer" arm became "withouthooksecurefuncorHookScript" -- borrow and answer still work, the debt simply has nothing to settle it. A new test drives the floor: the login install of the hooks, a foreign send holding our first click, the status line, the click past the floor sending, and two of our own held apart. AfloorPassedhelper moves the clock where a spec means "the player clicks again" (six sites).Tests/env_e2e.lua'sanswerWhomoves the shared clock to the first moment a send is allowed before each click -- a click inside the floor is held, and a player's next click is seconds away anyway.toc_spec: seven files in the table,_Camelotin the closed suffix probe, and the three doc tables read back with the new row.- 823 specs, 100% line coverage on the library,
env_guild.luaandenv_e2e.lua. - Not run in a client, either item. The
/whomechanism is traced in both codebases and in the Blizzard source; the pop is not reproduced here and FGI's side is also unverified in a client. Nothing of this library has run on a Forever client -- there is no install on this box, and whether CurseForge accepts a 16001 TOC is not known from this workspace (FGI v2.14.0 shipped one; the user can say whether that release went through).
0.9.1 -- the classic tab no longer errors at login on retail
The LibStub MINOR moves 22 -> 23. One fix; nothing an addon calls changed.
Fixed
- A Lua error at every login on a retail client,
CreateFrame(): Couldn't find inherited node "GuildFrameColumnHeaderTemplate"fromInstallClassicGuildTab, reported twice on 2026-09-19/20: by a FastGuildInvite user on WoW Forever (relayed by FastGuildInvite's session as peer review960cc9f9) and by the user on retail proper. My defect, from 0.9.0: the classic tab's install guarded onFriendsFrame,FriendsFrameTab4,frame.Bg, theFauxScrollFrame_*helpers andPanelTemplates_SelectTab, and retail has every one of those -- the same frame, a fourth tab (Quick Join,Blizzard_FriendsFrame/Mainline/FriendsFrame.xml:1119), the same helpers. What retail has not got is the Classic Guild page's two templates,GuildFrameColumnHeaderTemplateandFriendsFrameGuildPlayerStatusButtonTemplate(Blizzard_UIPanels_Game/Classic/FriendsFrame.xml:576,:408; in theclassic_era,classicandclassic_anniversarytrees of the Blizzard source and not inlive).CreateFrameon a template the client lacks is an error, not nil, so the header loop threw with the tab, page, inset and offline box already built and nothing settingrec.failed. - The fix asks the client for its templates before building anything:
hasTemplates(...)onC_XMLUtil.GetTemplateInfo, documented inXMLUtilDocumentation.luaon all four flavours withMayReturnNothing, so an unknown template answers nil.InstallClassicGuildTabasks for its five (FriendsFrameTabTemplate,InsetFrameTemplate,GuildFrameColumnHeaderTemplate,FriendsFrameGuildPlayerStatusButtonTemplate,FauxScrollFrameTemplate) andInstallGuildTabfor its two (CommunitiesFrameTabTemplate,CommunitiesMemberListFrameTemplate) -- the Communities install's mixin guards are a proxy for the same files and had the same shape of hole, so both paths go through the one helper. On retail the classic install now answers false and builds nothing; the Communities tab is unaffected. - A client with no
C_XMLUtilat all is answered TRUE, not false. Peer Review's proposed fix returned false there. Every Blizzard client has the API (all four documentation trees), so that branch is a private server on an older build -- Whitemane's 38000 family -- where the classic window and its templates exist and the tab built and worked before this check. Refusing there would remove a working tab to guard against a client that does not exist. - The
/grwindow's help tooltip said "Nothing here sends a /who". Untrue since 0.8.0, when Sync now began sending the owed/whofrom the click; the line predates that and was never swept. It now says a/whogoes out only from the player's own click, and that nobody unseen is whispered.
Docs
docs/Curseforge_Description.html: the loaded-MINOR list stopped at 0.8.1 (20) and now runs to 0.9.1 (23); the slash-command backup list namesmail,tabanddiagbesidesisters,pullandsync(the README's matching list too); the v0.7.0 and v0.6.1 blocks left "Recent Updates" for the full changelog, keeping the last five releases -- 7,806 characters of headroom under the 90,000 ceiling was one release block from a rejected page.
Testing
Tests/env_guild.lua'sinstallFriendsFrameModelnow modelsC_XMLUtil.GetTemplateInfo(the harness has none): a table for each name inguild.templates, which starts as the eight templateswrapGuildWindowTemplatesroutes, nil otherwise. A spec removes a name to play a client without it.classictab_spec: retail's Friends frame (every guard passing, the header template absent) builds nothing -- no tab, page, inset or offline box -- and the row template alone absent answers the same; noC_XMLUtil, and aC_XMLUtilwithoutGetTemplateInfo, both build the tab. The file's header said the tab was MINOR 23; it shipped at 22 in 0.9.0, corrected.rostersisters_spec: the Communities install answers false with either of its two templates absent, alongside its existing four-piece check.- Seen in game on 2026-09-20, both sides: the user logged in on retail with the fix synced -- no error -- and on Classic Era, where the Sisters tab is still on the Friends frame. The user: "i ran it in retail, fixed the issue. I ran it in classic, no issue."
Older versions (0.9.0 and earlier) are in CHANGELOG_ARCHIVE.md.
All Relations
- All Relations
- Embedded Library
- Optional Dependency
- Required Dependency
- Tool
- Incompatible
- Include

