LibGuildRoster

A library for use in WoW addons that manages the guild members and roster to make that data available for use in your addon.
Back to Files

GuildRoster-v1.2.1

File nameGuildRoster-GuildRoster-v1.2.1.zip
Uploader
PmptastyPmptasty
Uploaded
Oct 11, 2026
Downloads
12.4K
Size
267.0 KB
Flavors
Titan Reforged ClassicRetailMoP ClassicClassic TBCClassicForever
File ID
9140157
Type
R
Release
Supported game versions
  • 12.1.0
  • 12.0.7
  • 12.0.5
  • 5.5.4
  • 4.4.2
  • 3.80.2
  • 3.80.0
  • 3.4.5
  • 2.5.6
  • 1.60.1
  • 1.15.9

What's new

Changelog

All notable changes to LibGuildRoster-1.0 are documented here.

1.2.1 -- NormalizeName follows the regional-names switch

The LibStub MINOR moves 32 -> 33. No API change.

Fixed

  • NormalizeName's cache now keys on RegionalNames() as well as the realm. It wiped only when GetRealmName() changed, so a name normalized while RegionalUniqueNamesEnabled() was true kept its bare spelling after the switch went false, while GetNormalizedPlayer (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 review 740ceb25): its Forever specs flip the switch, and a later spec file saw a roster row keyed Me against a player key of Me-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", and sister_spec "re-keys a fed roster both ways when the regional-names switch flips" -- with the switch on, CanonName drops the realm half, so a stored Ana-Testrealm re-keys to Ana and 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.md and Tests/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 (docs and Tests are in .pkgmeta ignore:).

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 as GetNumGuildMembers counted. Backed by lib.rosterShort, set from the scan's own short when 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 after OnRosterReady (and after OnRosterUpdated on 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) -- kicked is true when the home message matched the kick patterns, false for "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) -- true online, false known offline, nil nobody said, for one sister member. Asked for by DeltaSync (contract 90f5fd1913f4): GetOnlineMembersScoped cannot 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: isOnline for a member, nil for anyone else.

Changed

  • The server's "No player named X" now records X as known OFFLINE. OnPeerUnreachable used to clear the sighting stamp, leaving "nobody said", so a consumer's guard let X straight through again. It now also stamps lib.offline[lower(guildKey)][X] in every sister roster holding X, and sisterOnline reads 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 by RemoveSisterRoster. 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 and IsRosterComplete stayed false for the session. lib.repairInProgress now spans a repair's retries and loginRepairs counts repairs started. lib.repairEvents is 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_spec pins kicked for both messages.
  • livestream_spec: six specs for GetSisterPresence -- nil when nobody said; false after a not-found with no earlier sighting (and absent from GetOnlineMembersScoped); a newer sighting or stream "on" beats it and an older relayed one does not; no /who input; 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. OnChatMsgSystem and OnChatMsgGuild skipped a payload only when IS_RETAIL (WOW_PROJECT_ID == WOW_PROJECT_MAINLINE) and C_ChatInfo.InChatMessagingLockdown() were both true. WoW: Forever runs the retail client -- its ChatInfoDocumentation.lua flags CHAT_MSG_SYSTEM, CHAT_MSG_GUILD and CHAT_MSG_OFFICER SecretInChatMessagingLockdown and leaves playerName without NeverSecret, and its FrameScriptDocumentation has issecretvalue -- but it is not WOW_PROJECT_MAINLINE, so the secret message reached message == "". Both handlers now return when isSecret(arg) (1.1.3's helper) says the argument is secret, on every flavour. Classic clients have InChatMessagingLockdown but never a secret payload, so they keep parsing through a lockdown as before.
  • chat_spec models a secret payload with issecretvalue on 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 /who floor instead of retail's 8 s. IS_RETAIL now also matches WOW_PROJECT_ID 18, which Forever reports: observed in a client on 2026-10-01 and recorded in FastGuildInvite's FGI_Compatibility.lua (Blizzard's forever tree defines no constant for it). FGI's fn.getMinScanInterval already gives Forever 8 s, so the two libraries now hold their sends apart by the same floor there. WHO_SEND_FLOOR is the only thing that reads IS_RETAIL; sistersync_spec pins 5 / 8 / 8 for Classic, Forever and Mainline.
  • 934 passed; coverage 100% on the library, env_guild.lua and env_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, UnitIsUnit and UnitIsConnected may return secrets, and comparing or testing one from addon code is a Lua error. A file-local isSecret (the client's issecretvalue, absent = nothing is secret) now guards all four: UnitKeyName returns nil for a secret name or surname/realm, and OnUnitLevel drops 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 no issecretvalue and take the old path unchanged.
  • sisterlevel_spec models a secret as a value issecretvalue names and asserts none of the four is read; .luacheckrc and .luarc.json declare the global.

Changed

  • Comments only, no behaviour: the GreenWall send helper and livestream_spec now record that GreenWall 1.14.0's SendMessage tried a channel send before its relay and raised ADDON_ACTION_BLOCKED 52 times on Classic Era on 2026-09-30, and that GreenWall's next release sends API data relay-only with the same signature (peer review d49c5edb3f3f), so nothing here changes.
  • The Tests/wowapi harness pin moves 1ccef14 -> 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 / UnitIsConnected guards 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 through lib:WhisperTarget(name): the baton's here answer to a guildmate's hi, 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's RequestFrom. On a client where RegionalUniqueNamesEnabled() is true it is CanonName(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 bare First Last is what Blizzard's own whisper UI implies; not verified on a Forever client.

Tests

  • normalize_spec: WhisperTarget drops the realm on Forever and leaves a name untouched on every other client.
  • livestream_spec: the here answer is addressed exactly as the sender arrived on a normal client (a same-realm sender is bare, Zed), and to Zed Stonefist for a Forever sender Zed 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:WhisperTarget to 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 and docs/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"). NormalizeName appended the viewer's internal realm to every bare name, so one character could key differently on two clients with different internal realms -- the same divergence CanonName exists to prevent on connected realms. Now, when the client's own RegionalUniqueNamesEnabled() says so, NormalizeName never appends a realm and CanonName drops a -Realm suffix 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. GetNormalizedPlayer read only UnitName("player")'s first return; on Forever the second return is the SURNAME (Blizzard's Camelot/NameUtil.lua joins exactly those two), so the player was keyed as First-Realm. It is now First Last.
  • A party member's level reading treated the surname as a realm. OnUnitLevel built name .. "-" .. second from UnitName(unit), which on Forever made First-Last. It goes through lib: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, and UnitKeyName on both kinds of client.
  • env_guild.useRegionalNames(first, surname, sep) models a Forever client, and resetState clears 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:UnitKeyName rather than bare UnitName, 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 /who rows 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, StartLive sends hi on 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.
    • 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, and livestream_spec pins 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_CHUNK characters each). These answer the want that 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/off in lib.live, and a complete snapshot marks everyone it omits offline, except its sender.
      • It fires OnSisterLocationsUpdated.
      • GetOnlineMembersScoped now counts a stream on while that guild's stream has spoken within LIVE_STREAM_TTL. A stream off clears the member's sighting, so a later sighting still wins.
      • The server's "No player named X" reply (OnPeerUnreachable) now also overrides a stream on.
      • The guild tab rows let a newer stream entry beat an older /who sighting.
    • 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 after LIVE_STREAM_TTL and 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 home GetMember today.
  • Library users report their own zone (OnZoneChanged). On ZONE_CHANGED_NEW_AREA the client sends one zone:<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-second C_GuildInfo.GuildRoster poll. 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_UP carries no unit and is the player's own; UNIT_LEVEL names 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's OnLevelComm takes it from a home member -- proof of a live ding, and proof of presence the same as guild chat. "unit": UNIT_LEVEL for 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 in MarkMemberOnline). The held level is member.level, so GetMember stays 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_LEVEL for 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 /who or pull had read their level before the ding, so the first reading after it could only be a baseline. OnPlayerLevelUp now also sends lvl:<level> with GreenWallAPI.SendMessage("GuildRoster", ...) while a sister guild is listed, when GreenWall's API is version 2 (which sends with no key press, GreenWall API.lua:37-38) and C_AddOns knows this library's id (GreenWall asserts it, API.lua:59; an embedded copy with no GuildRoster folder skips). OnGreenWallLevel places 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 fires OnSisterMemberLevelChanged source "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 with oldLevel nil. A sender we cannot place, or in an unlisted guild, is dropped. Not changed: a /who sighting 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 /who level 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, a GUILD_ROSTER_UPDATE after ready re-reads the rows' LEVELS (ReadRosterLevels, source "roster") when it answers our request or when LEVEL_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's canRequestRosterUpdate says 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 a C_Timer.NewTicker and 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_spec unchanged). 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 /who row about a sister member (recordLocation, source "who") and every member of a SetSisterRoster landing (source "roster") is a reading, filed in the new in-memory lib.sisterLevels[lower(guildKey)][charKey] = { level, online } by noteSisterLevel. 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 /who row is online by definition; a roster reading is online when presence (MarkOnline) or a /who row saw them within PRESENCE_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 /who and then a pull fires once; the home guild is never recorded. guildKey is 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' /who override, and knownOnline. Now the newest statement wins among a sighting, the stream and (for rows and level readings only) a /who row. GetOnlineMembersScoped still leaves /who rows out on purpose, because a /who row 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" (OnPeerUnreachable now marks the /who record offline).
    • gwSend counted a message GreenWall refused (ok = false) as sent.

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_CHUNK was 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_spec runs our real sends through GreenWall's real Relay.lua, Constants.lua and Base64BCA.lua from 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 /who answers 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 from PLAYER_LEVEL_UP is 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 /gr checkbox 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 tab status 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 (Mainline SharedUIPanelTemplates.xml:603), and retail has no BottomBorder -- its frame is a NineSlice child (:550). The strip ended short with no border under it. On a window with no BottomBorder and a NineSlice.BottomEdge, the strip now runs to the bottom of that edge piece and rec.footEdge redraws it over our stone: same atlas (or texture and coords), same tiling, SetAllPoints on theirs. Clients with a BottomBorder are unchanged. Two specs in rostersisters_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), and LiftGuildTabPage lifts rec.info above 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 InviteButton belongs 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, so BuildGuildTabInvite makes 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), and SyncGuildTabInvite shows it on every ShowGuildTab only while theirs is hidden, enabled by CanGuildInvite() as theirs is. Not built on a client without CommunitiesUtil. 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. EVERY WHO_LIST_UPDATE feeds 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 writes online = false. It is deliberately not lib.presence: presence picks who to pull a roster from and means "heard on the wire, runs the library"; a /who row says only "online". BuildSisterRosterRows takes the NEWER of the two for online (each good for PRESENCE_TTL), the /who zone while it still says online, and the /who level 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 callback OnSisterLocationsUpdated(lowerGuildKey); both tabs repaint on it.
  • Automatic, from a click the player was making anyway. Opening either guild tab (ShowGuildTab, ShowClassicGuildTab) calls lib:RefreshSisterLocations(force, onlyKey), which owes one /who per listed guild, paced by LOCATE_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 by lib:SplitLocate(spec, levels): level band first, cut by a port of FGI's fn.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 own WHO_TAG_CLASS and localised class names, limited to the classes the held copy has at that level. Bounded by LOCATE_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 carry sisterKey). lib:LocateMember(name, key) queues a /who by 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 /who of ours would go out on the next click (past WHO_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 read whoSync.queue directly; thread c5a1a43e). Location queries are deliberately not counted. The guild-step half is heldByGuildStep, a read-only mirror of waitingOnGuild sharing the guildmate search (guildmateToAsk, split out of PullFromGuildmates).
  • lib:QueueLocate, lib:TakeLocateAnswer, lib:WhoPaneIsPlayers are 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_WIDTH 300 -> 236, CLASSIC_TAB_SEARCH_WIDTH 316 -> 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 /who step whenever whoSync.queue is 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 by whoSync.sendingOurs around our own call) or foreign (lastForeignAt), and while a foreign sender was active within LOCATE_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: QueueWho absorbs a waiting location query of the same text and the spec rides the sync's answer, and keepLocate moves 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". SendQueuedWho held every /who while FriendsFrame was 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, Classic FriendsFrame.lua:53): our registration is borrowed while a query is out, so the answer cannot reach WhoList_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 -- HookWhoPane hooks WhoFrame's OnShow (the sub-frame FriendsFrame_ShowSubFrame("WhoFrame") shows, Classic FriendsFrame.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 /who could still be out, and that answer would switch the window to the Who tab. Mirrors FGI v2.14.6's whoPaneOf (thread c5a1a43e).
  • 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 /who answered with rows and none of them in that guild (FGI's level band, the player's own /who) is passed over: outstanding and the asked FIFO 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 (thread c5a1a43e, audit 7033364662e7). 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 /who record 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 /who sent and never answered no longer blocks every later refresh. whoPending counted a text in the asked FIFO 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 /who went 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 /who filled 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 the asked FIFO, 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 /who was 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 past WHO_SEND_FLOOR is 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 its AttachTooltip, 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 as LibGuildRosterDB.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 /gr window keeps itself current. Its list and its Diagnostics block repainted only on six library callbacks, none of which a sighting, a /who answer 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 to OnSisterLocationsUpdated.
  • 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 /gr window's guild list drew no rows. Its host is an AceGUI SimpleGroup, whose LayoutFinished sets 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 the SetHeight(0) calls; fixed with AceGUI's own noAutoHeight. My defect since the window was built. Not seen in game.
  • WoW Forever: a sister-guild /who opened the Group Finder on its Who tab. Found by FastGuildInvite (peer review 1a5ce19f) in the forever tree: the Group Finder's LFGWhoListFrame registers WHO_LIST_UPDATE in its OnLoad and, while C_SocialUI.IsSystemEnabled() is false, answers with ShowUIPanel(LFGParentFrame) (Blizzard_GroupFinder_VanillaStyle/Mainline/WhoList.lua:165, :236-239). The library held only FriendsFrame, so the Forever pane got every answer. My defect since 0.9.2 shipped a Forever TOC. Now every Blizzard Who host in lib.WHO_PANE_FRAMES that exists is held (BorrowWhoPanes), the ones held are remembered in whoSync.heldPanes, and GiveBackWhoList hands back exactly those. The Group Finder is load-on-demand, so a load under a standing debt is taken at ADDON_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's GetFramesRegisteredForEvent walk: the two hosts are known from the source, and the walk needs a harness model not yet delivered (FGI's contract f9c248f0). 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, OnGuildTabChanged and OnSisterLocationsUpdated; its MINOR list stopped at 0.9.2; and it named neither GuildTabRowMenuItems, ResetGuildTabView nor the Who-host methods. The README's Callbacks section lacked OnSisterLocationsUpdated, and its /who paragraph named only FriendsFrame. Both now say that OnSisterLocationsUpdated can fire with no guild key (the /who answer handler's final Fire).

Testing

  • Harness pin bb80c1b -> 1ccef14, taking 0a1a896 (contract f275ebec: a hollow wow.reset() evicts the widget layer's globals, so the two hand-written scroll-plumbing nils in Tests/env_guild.lua are gone). Four search specs went red on it; every one was the env, not the library:
    • Tests/env_guild.lua still stood in for C_Club.GetGuildClubId, the two Club enums and C_CreatureInfo.GetClassInfo -- all the harness's own since 6770e26, which I asked for (inbox 8e9aa2eb) and never adopted. The stand-in answered club id 7; frames.reset() now reinstalls the harness's GetGuildClubId (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 on wow.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.
    • ResetGuildTabView cleared the query before SetText(""), whose OnTextChanged then wrote "" back. Nothing drew differently (empty is no filter), but the state was not the "no query" the function promises; the nil is 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 as 1ccef14, 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 own cfhtml.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 as 8bf857eb), so it lives in the suite instead.
  • The suite is order-independent now. run.lua --order reverse was red (5), and both causes were mine. zz_window_spec.lua loads LibAceGUIWidgets and nothing let it go, so every later spec saw a widget library the plain env lacks: /guildroster opened 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. And sister_spec's exact-PRESENCE_TTL boundary misjudged from a fractional clock (the harness clock never resets, and (t + 900) - t is not exactly 900 in floating point); it now starts on a whole second. At pin 1ccef14: default, --order reverse and --order shuffle:1790463113 are 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 /who zone in the Zone column and in the hover card, beside the guild name in the Rank column. The /gr window's list draws its row again (The Other Gods, 288 members, 7 online, held), and a location /who went 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 the whoPending fix, the library's own location /who went out from a click and filled the sister zones with no manual /who, the /gr window 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_CLASS is read at call time, and the class axis simply does not split on a client without it. Correction from Peer Review (thread 7059e8cf): 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 /gr screenshot, 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 /who out 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's FriendsFrame is the only frame registered for WHO_LIST_UPDATE, and its handler ends in WhoList_Update -> ShowUIPanel(FriendsFrame). Both libraries borrow that registration off while a background /who is out, and each handed it back when ITS OWN answer arrived -- mine one frame after the event (RestoreFriendsFrame). Nobody can tell whose answer a WHO_LIST_UPDATE is, 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-hooks lib: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, Classic FriendsFrame.lua:442, :480), and C_FriendList.SendWho with a non-nil origin: every Blizzard call site passes an Enum.SocialWhoOrigin (SlashCommands.lua:1111 Chat, FriendsFrame.lua:1233 Social, ItemRefHandlers.lua:50 Item, in all four flavour trees; the argument is Nilable = true in FriendListDocumentation.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 reinstalls FriendsFrame and C_FriendList between spec files.
  • The send borrows unconditionally rather than only while borrowed is 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 /who path 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 /who is held inside the server's floor of the last /who ANYONE sent (WHO_SEND_FLOOR: 8 s on retail, 5 s elsewhere -- the operator's own measurements recorded in FGI's fn.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 whose WHO_LIST_UPDATE it 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. The SendWho post-hook stamps every send whatever its origin, SendQueuedWho stamps 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 at PLAYER_LOGIN as 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 like lastWho; 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 _Mainline file list verbatim, for World of Warcraft: Forever (Battle.net product wow_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.toc ahead 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 _Camelot TOC and hard-depends on this library, so without ours it did not load there. WOW_PROJECT_ID is WOW_PROJECT_MAINLINE on Forever (no constant of its own), so IS_RETAIL takes 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. What GetNormalizedRealmName and /who rows return on a server with no realm names is unpublished and untouched.

Docs

  • README: the /who paragraph 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 /who bullet 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; a SendWho with 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 /who is theirs); the degrade test's "without C_Timer" arm became "without hooksecurefunc or HookScript" -- 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. A floorPassed helper moves the clock where a spec means "the player clicks again" (six sites).
  • Tests/env_e2e.lua's answerWho moves 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, _Camelot in 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.lua and env_e2e.lua.
  • Not run in a client, either item. The /who mechanism 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" from InstallClassicGuildTab, reported twice on 2026-09-19/20: by a FastGuildInvite user on WoW Forever (relayed by FastGuildInvite's session as peer review 960cc9f9) and by the user on retail proper. My defect, from 0.9.0: the classic tab's install guarded on FriendsFrame, FriendsFrameTab4, frame.Bg, the FauxScrollFrame_* helpers and PanelTemplates_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, GuildFrameColumnHeaderTemplate and FriendsFrameGuildPlayerStatusButtonTemplate (Blizzard_UIPanels_Game/Classic/FriendsFrame.xml:576, :408; in the classic_era, classic and classic_anniversary trees of the Blizzard source and not in live). CreateFrame on 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 setting rec.failed.
  • The fix asks the client for its templates before building anything: hasTemplates(...) on C_XMLUtil.GetTemplateInfo, documented in XMLUtilDocumentation.lua on all four flavours with MayReturnNothing, so an unknown template answers nil. InstallClassicGuildTab asks for its five (FriendsFrameTabTemplate, InsetFrameTemplate, GuildFrameColumnHeaderTemplate, FriendsFrameGuildPlayerStatusButtonTemplate, FauxScrollFrameTemplate) and InstallGuildTab for 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_XMLUtil at 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 /gr window's help tooltip said "Nothing here sends a /who". Untrue since 0.8.0, when Sync now began sending the owed /who from the click; the line predates that and was never swept. It now says a /who goes 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 names mail, tab and diag beside sisters, pull and sync (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's installFriendsFrameModel now models C_XMLUtil.GetTemplateInfo (the harness has none): a table for each name in guild.templates, which starts as the eight templates wrapGuildWindowTemplates routes, 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; no C_XMLUtil, and a C_XMLUtil without GetTemplateInfo, 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."

0.9.0 -- a guild and sister guilds tab on BOTH guild windows

The LibStub MINOR moves 21 -> 22. Additive: nothing an addon calls changed shape; methods and one callback were added (listed below).

The user, 2026-09-17: "like how we did the mailbox integration with the sister guilds ... can we populate the wow guild roster with alt guildies?" Asked whether it could be done without taint: "lets try it, and see, then we can back it out if it's taint or fix it." The try is recorded under "What was tried first"; the measurement backed it out, and what ships is a tab of our own on the Communities window -- and, the next day, a second one on the classic Friends-frame guild window.

Both tabs ship here. The classic tab was offered on 2026-09-18 as "option 1, as its own release after 0.9.0" and chosen with "do 1"; it was then built, and seen working in game, the same day 0.9.0 was being prepared. The user, that evening: "they are both working, lets prep for a release, lets get the changelog and .html updated for v0.9.0". Between those two, this file briefly held the classic tab in an "Unreleased" section on Peer Review's reading of the earlier words (thread fd0ed643); the later words settle it.

Added

  • A fourth tab on the Communities guild window, below Blizzard's Chat, Roster and Guild Info tabs, built from their own CommunitiesFrameTabTemplate and anchored under GuildInfoTab as theirs are under each other. Its page is a frame of OURS inheriting CommunitiesMemberListFrameTemplate, so Blizzard's own mixin draws Blizzard's own row template -- it looks like the Roster page -- from tables that are all ours. The user's words for the shape: "can we rebuild the frame exactly and replace it with ours, and then modify it?", "i'd rather match the games", and on a separate window, "but that just opens another window".
  • Its rows: guildmates from C_Club.GetClubMembers / GetMemberInfo (the pair Blizzard's list reads, so zone, level, rank and note are live), each a COPY with isSister, online and a realm-qualified charKey added; the library's own home roster when the client lacks that API. Sister members from BuildSisterRosterRows as before. One list, sorted by the clicked column (Blizzard's Rank column sorts on guildRankOrder, which our rows do not carry, so it reads the rank text), default online first then by name.
  • The widget library's search box above the columns (W:CreateSearchBox, filtering with W:SearchMatch across name, key, rank or sister-guild name, note and zone). The user: "add our libaceguiwidgets search box to filter through the list". Without LibAceGUIWidgets there is no box and the query is ignored.
  • Clicks: left whispers through ChatFrameUtil.SendTell (the real Classic Era API; the bare ChatFrame_SendTell is only a deprecation fallback behind loadDeprecationFallbacks, used when the namespaced form is absent); right opens a Whisper / Invite to group menu, the invite through C_PartyInfo.InviteUnit (Classic Era has no bare InviteUnit). Officer actions stay on Blizzard's Roster tab: SetGuildRankOrder, SetNote and RemoveFromGuild are HasRestrictions, so they cannot run from our frame.
  • Settings, account-wide at the root of LibGuildRosterDB: guildTab (the tab, default ON), guildTabDefault (the guild window opens on our tab, default OFF -- the user: "when you push the J key it opens our replication instead of theirs, turned on/off in a setting"), and guildTabOffline (our page's own Show Offline, so Blizzard's communitiesShowOffline CVar is never written). Two checkboxes in /gr, /guildroster tab on|off|default|nodefault, and a "Guild window tab ... Built ... Opens on it" diagnostics line.
  • API: IsGuildTabEnabled / SetGuildTab, IsGuildTabDefault / SetGuildTabDefault, IsGuildTabShowOffline / SetGuildTabShowOffline, the OnGuildTabChanged(enabled) callback, BuildHomeGuildRows, BuildGuildTabRows(opts), BuildSisterRosterRows, and the frame half -- InstallGuildTab, ShowGuildTab, HideGuildTab, RefreshGuildTab, SyncGuildTabVisibility, OnGuildWindowShow, InitGuildTabRow, OnGuildTabRowClick, GuildTabRowMenuItems, WhisperName, InviteName.
  • Install at PLAYER_LOGIN and on ADDON_LOADED for Blizzard_Communities, recorded on lib.guildTab so a LibStub upgrade does not build a second tab.
  • The CLASSIC guild window gets a tab too: a fifth across the bottom of the Friends frame, for players on "Use Classic Guild UI" -- who, with the tab above, still saw nothing. The user, 2026-09-18: "on the classic UI, there is nothing, can we add a tab to that like we did the new UI?", and of three shapes offered (a fifth tab; a third view on their Guild page, which writes their menu; a button of ours swapping lists in place), "do 1". "Sisters", built from their FriendsFrameTabTemplate after Raid at their -14 overlap, shown exactly when their Guild tab is (their FriendsFrame_UpdateGuildTabVisibility and InGuildCheck post-hooked). A lookalike we manage ourselves: not registered with PanelTemplates, which would write FriendsFrame.numTabs; pressed and un-pressed with the same PanelTemplates_SelectTab / DeselectTab their code uses (widget calls, on ours and on the one of theirs left pressed -- their selectedTab is never written). The page covers their Bg's rectangle down to the top of their bottom border in the window's stone, and is laid out from their Guild page's own XML with their own templates: the Show Offline box, four GuildFrameColumnHeaderTemplate headers (Name 83, Zone 120 -- 105 with a scrollbar -- Lvl 32, Class 92), thirteen FriendsFrameGuildPlayerStatusButtonTemplate rows from (3,-82), a FauxScrollFrameTemplate 296x237 at (-32,-87) with their scrollbar art, the totals line, the horizontal bar, and our search box where their page has the MOTD. Rows come from the same BuildGuildTabRows; a sister member's guild is in the Zone column, having no zone. Painted the way GuildStatus_Update paints theirs (gold name and white columns online, all grey offline). Same settings, same click actions, officer actions on their Guild tab. Two things the read-through after green added: their own tab pass (InGuildCheck runs PanelTemplates_UpdateTabs) re-presses their tab under our open page, so SyncClassicTabVisibility presses ours again after it; and the Show Offline box follows the shared setting on every repaint, not only drives it. And one the self-audit found: with the window already open, a KEYBIND (ToggleFriendsFrame(tab), OpenFriendsFrame) moves their tab through PanelTemplates_SetTab and a direct FriendsFrame_OnShow() call -- no tab click, no OnShow script -- so ours stayed over their new page. A post-hook on PanelTemplates_SetTab puts ours away when the Friends frame's tab really moved from the one ours was opened over, and leaves it when the same tab is re-asserted (GuildStatus_Update does that on every roster event). Peer Review enumerated the paths that reach it from the Era tree -- the O key (TOGGLEFRIENDSTAB), TOGGLEWHOTAB, the GUILD_ROSTER slash command (SlashCommandsOverrides.lua:193), the chat social button, ShowWhoPanel -- and every one calls PanelTemplates_SetTab before its direct FriendsFrame_OnShow() (FriendsFrame.lua:1210, :1223, :1238; the tab template's OnClick at xml:727), so the one hook covers all of them. InstallClassicGuildTab, ShowClassicGuildTab, HideClassicGuildTab, PressClassicTab, RefreshClassicGuildTab, PaintClassicRow, SortClassicGuildTab, SyncClassicTabVisibility, OnFriendsFrameShow; a "Classic tab built" clause on the diagnostics line. Same MINOR as the Communities tab: one release, one increment.

How it stays out of Blizzard's taint

Four rules, each the direct lesson of the measurement below, written in full in the section header:

  1. Never write into a Blizzard table -- member arrays, data provider, CallbackRegistry.
  2. Never call a Blizzard frame's Show/Hide/SetShown, nor CommunitiesFrame:SetDisplayMode. A Show from tainted code runs that frame's OnShow tainted, and CommunitiesMemberListMixin:OnShow registers callbacks on CommunitiesFrame. So our page is drawn OVER their content area with its own opaque background; their page stays shown underneath. Measured, not argued: switching their window to its Roster page before ours showed was tried in game (the user's suggestion, after the Chat page's input box drew through ours), and select(2, issecurevariable(CommunitiesFrame.MemberList, "sortedMemberList")) then read "GuildRoster". The switch was removed the same day; the measured lift below covers the chat box instead.
  3. Post-hooks only: hooksecurefunc on SetDisplayMode (a Blizzard tab click puts our page away) and on UpdateCommunitiesTabs (our tab follows the Guild Info tab's visibility, so a community or the finder shows none), and HookScript on the window's OnShow (opens on our page or theirs, decided afresh each time).
  4. The template's scripts are replaced before the page can show or hide anywhere. The page is created under a hidden HOLDER of ours that answers the three club questions with nil, because the template's OnLoad runs inside CreateFrame and reaches its "communities frame" through GetParent; only once OnShow / OnHide / OnUpdate / OnEvent are ours does it move to the guild window. Every acquired row has its events unregistered and its OnShow cleared, so it never re-reads itself through C_Club.GetMemberInfo.

Two layout decisions, both from the first in-game looks:

  • The page's frame level is MEASURED on every show, and its strata is always HIGH: the highest shown frame anywhere in the guild window's tree (at HIGH or below), plus 20. Two guesses failed in game first -- the window's level plus 20 lost to their list's border, pinned at 100, and a fixed 300 lost to the chat box at 1200. Then the strata: taking HIGH only while something shown was in it (the chat pane's scrollbar), the page opened from the Roster or Guild Info tab was measured shown, MEDIUM, level 531, over a window at level 1 whose member list was at 2 -- and their page still showed through, while from Chat, in HIGH, ours showed. What was over us in MEDIUM is not established (nothing in Blizzard_Communities' XML puts a roster or info frame above it); HIGH is the one strata that leaves the window's DIALOG popups above us, so the page takes it every time. Confirmed in game the same day: the user, "i confirm the tab switching works now correctly".
  • The slider column left of the list is redrawn whole. Their communities-list scrollbar sits there over the window inset's marble, and the Chat page's input box hangs its corner art into it. Three patches round the art each looked wrong in game; the user: "we need to fully redraw over this slider to fix our issue, just do that". So the column is the window inset's marble, aligned to the inset's own Bg, and it runs only BETWEEN the inset's own top and bottom border pieces (the TopEdge / BottomEdge textures on the inset's NineSlice child): the column sits above the window's inset, so one running the inset's full height painted those two lines out, and copies of the tiles drawn back on the column were wrong too (the user, with crops of the game beside ours: "see how you covered up the natural top borders of the scrollbar, you shouldn't", the same for the bottom, and of the copy, "a strip you added in there? it shouldn't"). Nothing of ours draws on either line now. The window's lighter stone was tried twice in between, each time read off a screenshot, and sent back both times ("you regressed to this") -- on our page, and on it a MinimalScrollBar of our own at exactly their bar's anchors that mirrors theirs -- extent, allowed, position, shown -- from a hooksecurefunc on their bar's Update, and again on every show of ours. Ours takes no mouse, so a click falls through to their real bar and their list scrolls as before; nothing of theirs is written.
  • The tab hangs under the LAST button below Guild Info, not under Guild Info itself. GRM puts its roster button there (GRM_UI.lua:16688) and ours sat on it. It keeps the same centre-to-centre spacing as Blizzard's own tabs (a 32-high tab plus their 20 gap), re-placed on every tab pass.
  • The Chat page's input box is painted out with Blizzard's own textures, lined up with Blizzard's own. Left shown under us, the box hangs below the list and its left corner art reaches 10 into the column left of our list, where the communities-list scrollbar sits (CommunitiesChatFrame.xml:65-69). Two things cover it: the window's Bg stone, clipped to the strip under our list (from under the window inset's own bottom border piece -- a strip starting at the inset's rect shaved the lowest row off that line -- down to the top of the window's bottom border, not the Bg's bottom, which painted over that border), and the slider column redrawn whole (above), which stops at our list's edge where our own InsetFrame's border -- the same InsetFrameTemplate edge the game has beside that column -- draws above it. Four patches preceded that, each wrong in game: the list's marble over the box (wrong colour, 4 short of the corners), a 2-wide marble strip beside the corner art, marble and an edge tile over the art -- a black block over the grey border itself (the user: "the black background is overlapping and covering the 'issues' and it looks bad. we need the fix to be us deploying over the actual grey border edge, not extending the background over the border") -- and then a second copy of our list's border, all eight NineSlice pieces drawn over everything, which doubled the edge. MinimalScrollBar itself has no background or border of its own -- only track and arrow atlases -- which is why the redraw is a whole bar.

The one widget call made on a Blizzard frame is SetChecked(false) on their four tabs when ours is clicked. Nothing protected runs from a tab, and UpdateCommunitiesTabs only ever writes that state.

What was tried first, and why it is gone

The first cut appended sister rows to Blizzard's own Roster page with a post-hook on CommunitiesFrame.MemberList.RefreshListDisplay, and a post-hook on the entry mixin's Init set isInvitation on our rows to make their clicks dead. It worked in game -- the user: "it's working, they populate" -- and two things were found there:

  • A negative memberId was rejected by the client, 40 BugSack errors from Blizzard's row handler re-reading each member on GUILD_ROSTER_UPDATE. The id base became 2147483647 counting down; it is kept, positive, on the sister rows today.
  • Health-check command 8 (issecurevariable per row, naming the tainting addon) read "GuildRoster" on EVERY row, the player's own guildmates included. My containment argument -- that the scroll box recycles frames by identity, so a guildmate's row is never re-initialised inside our insert -- was wrong: the scroll box's own Lua bookkeeping is rewritten inside the insert, and every later secure refresh reads it. The rows also snapped the scroll position on each roster event, from the same cause.

AppendSisterRosterRows, OnRosterEntryInit, InstallRosterSisters, IsRosterSistersEnabled / SetRosterSisters, OnRosterSistersChanged, /guildroster roster and health-check command 8 are removed. None of them was ever released. The saved rosterSisters key is no longer read.

Not covered, said plainly

  • The classic window's tab, seen in game on 2026-09-18 -- the user, after opening both: "they are both working". It was built from the Guild page's own XML with 25 specs on a model of the window that has no layout, so that first look was the geometry check; what the user ran beyond opening it (sorting, Show Offline, scrolling, the O key with ours up, an officer note edit on their Guild tab) was not itemised and is not claimed here.
  • PanelTemplates_DeselectTab on one of their tabs calls tab:Enable() and re-anchors its text -- widget calls, the same class as SetChecked on the Communities tabs. Peer Review searched the Era AddOns tree for a secure reader of a Friends tab's enabled state or of FriendsFrame.numTabs and found none; a grep absence, stated as such. A widget's enabled state is not a Lua variable, so issecurevariable cannot measure it; an officer note edit on their Guild tab after using ours is the check, and it has not been run on the classic window.
  • Seen in game on 2026-09-18, on a Classic Era client with GRM installed: the tab under GRM's button; guildmates and sister members in one list with the search box and online count in the band above it; the page opening from the Chat, Roster and Guild Info tabs (the last two only after the strata fix above, measured); the page's background, border and the redrawn slider column matching Blizzard's own Roster tab through nine rounds of screenshots; column sorting (the Rank and Note headers were clicked in the user's shots); and /dump select(2, issecurevariable( CommunitiesFrame.MemberList, "sortedMemberList")) printing nothing after the page switch was removed -- with the switch in, it had printed "GuildRoster". And the check that matters, done last: the user, an officer, edited a member's note on Blizzard's own Roster tab after using ours -- no error -- and the same /dump afterwards printed nothing. The officer path is clean with the tab in use.
  • Not seen in game, said plainly: typing in the search box; a left-click whisper and the right-click Whisper / Invite menu; the slider replica scrolling their list; the rank dropdown (the note edit is the same restricted path, SetNote beside SetGuildRankOrder, and it passed). /guildroster tab off is the back-out.

Fixed

  • A sister member who logged off kept reading online for as long as two guildmates kept relaying (TOGBankClassic's peer review 653048d5688e, 2026-09-17, confirmed at OnSisterRosterComm's two MarkOnline calls). The relay's o carried bare names and every receiver stamped them at receive time, so a name any one member saw once circulated: A relays X, B stamps X fresh, B relays X 270 s later, A stamps X fresh, indefinitely -- and the same-hash relay restarts the pull clock, so no first-hand pull ever replaced it. o now carries each name's AGE -- { ["Name-Realm"] = seconds since the relayer last saw them } on the GUILD relay and on a guildmate's offer -- and MarkOnline stamps a relayed name now - age, never moving an existing stamp backwards and dropping an age past PRESENCE_TTL. A first-hand sighting (an array of names, as before) is still stamped now. A bare name list from a 0.8.x relayer is not stamped: it cannot be dated, and stamping it fresh would put this client back into the loop. So in a guild mixed between 0.8.x and 0.9.0, a 0.9.0 client sees only first-hand presence until the relayers update; that cost is taken over keeping the loop alive. pendingPull.presence and a guild offer's o carry the map through to the roster landing. The new tab's sister rows read the same presence, which is why this is in the same release.

Changed

  • The mailbox autocomplete hook is tainted too, and that is now written down in its header: AutoComplete_Update calls our wrapper, so the five suggestion buttons and the dropdown's selection state are tainted for that keystroke. The Send button reads the To: box fresh from the widget and calls SendMail (MailFrame.lua:864-866) without touching anything we wrote, so the restricted call never runs tainted by that trace. Not measured with a taint log.

Testing

  • Harness pin acac151 -> bb80c1b. The window specs were red at acac151 before any change this session: LibAceGUIWidgets moved its resize framework into LibAceGUIWidgets-Resize.lua, the harness manifest learned the file in 2aac908, and this repo's pin predated that. Every entry between the two pins was read; none asked anything of this addon beyond running the suite, which is green.
  • Tests/rostersisters_spec.lua (new): the three row builders pure, the settings, the install and its refusals, showing and hiding against Blizzard's tabs, the rows' disarmed events and their clicks, and the page's own controls. Each of Blizzard's template scripts and methods that the library must replace records that it ran, so "none of theirs ran through a whole open-show-hide cycle" is an assertion; a companion spec proves the modelled row really re-reads itself and raises on a negative id, so the "no handler ran" assertions are not vacuous. The widget-library examples (search, search box, menu) run on env.frames' widget layer with the real LibAceGUIWidgets -- on the hollow layer its search box indexes a field that layer answers with a function.
  • Tests/classictab_spec.lua (new, 25): the classic window's tab -- the install and its refusals (no Friends frame, no Raid tab, no scroll helpers, setting off, no bottom border, no WhoFrameColumn_SetWidth), visibility following their two passes and the guild, showing and hiding against their four tabs with their selectedTab asserted untouched, the keybind move putting ours away while a same-tab re-assert and another panel's SetTab do not, the painted rows (names, sister guild in Zone, level, class, the online and offline colours, the scrollbar's column widths), paging through the FauxScrollFrame, header sorts, Show Offline on our setting with their CVar asserted unmoved, the click actions, the missing-helper fallbacks, the library's signals, the open-on-ours rule that ignores the Friends tab, the search box on the widget layer, and the diagnostics clause. Every script and helper of Blizzard's the library must not run records that it ran.
  • Tests/env_guild.lua models the classic window too: FriendsFrame with Bg and BottomBorder, its four tabs whose OnClick sets selectedTab the way PanelTemplates_Tab_OnClick does, the PanelTemplates_* helpers by global name, the FauxScrollFrame_* helpers, WhoFrameColumn_SetWidth, FriendsFrame_UpdateGuildTabVisibility on a classicUI switch and InGuildCheck; the classic templates route to a modelWidget (a model frame with a global name, sizes, text, an id, enable state, font strings and button textures), the row template registering its four named regions. And a reset fix the new file's sort position exposed: the widget layer's CreateScrollBoxListLinearView and ScrollUtil survive the hollow reset, so after any spec that switched layers the hollow model stood aside -- which model ran was a function of spec-file order. The hollow reset now clears both first. That is a leak the harness owns (env/frames.lua installs both and wow.reset leaves them); it is filed to WoWAPITesting's inbox as contract 586d96c4a0f0, and the local clear stays until the pin that closes it.
  • Tests/env_guild.lua models the guild window: CommunitiesFrame with its four tabs, SetDisplayMode and UpdateCommunitiesTabs; the tab template; the member-list template with its scroll box, Show Offline button and column header; and the row template, all through a CreateFrame wrapper (wrapGuildWindowTemplates, re-applied by a spec that switches layers). Plus C_Club.GetGuildClubId / GetClubMembers / GetMemberInfo (a fresh copy per call, raising on a negative id as the client did), C_CreatureInfo.GetClassInfo, the Club enums, and ChatFrameUtil.SendTell as a FIELD on the harness's own table. The invite is the harness's own C_PartyInfo.InviteUnit (wow.invites).
  • Tests/showoffline_spec.lua clears CommunitiesFrame after switching to the widget layer, whose CreateFrame no longer routes the window's templates; those specs are about the show-offline setter, and the tab's no-window branch is covered elsewhere.
  • Tests/healthcheck_spec.lua: seven commands again; command 8 and its four specs went with the in-list rows.

0.8.2 -- sister guild ranks, and same-realm sister members are heard

The LibStub MINOR moves 20 -> 21. Additive: nothing an addon calls changed shape, and one method was added (GetRosterRankHash).

The user, 2026-09-16, on VersionCheck's window showing a blank Rank for Testthese-Azuresong beside ranked home members: "can you capture this data so it's included in the sister roster sync?" -- and "we'll make that part of v0.8.2". The blank was not a capture gap in the home roster: VersionCheck reads rank through GetMember, which is home-only, and the sister wire had deliberately carried no rank since 0.7.0.

Added

  • Rank on the sister wire. Every served member now carries rn (rankName) and ri (rankIndex) beside n, c, l, pn, omitted when absent. Built in wireMember, read and TYPED in wireToFeed (a string name, a number index, anything else dropped -- it is concatenated into a hash).
  • Sister members carry rankName and rankIndex, the home record's own spellings, so a consumer reads a sister member the way it reads GetMember. SetSisterRoster takes them; the older free-form rank is still stored as-is and fills rankName when it is a string or rankIndex when it is a number. PersistSisterRoster saves both, so a relog keeps them.
  • lib:GetRosterRankHash(key) -- the third "has anything changed" hash, over charKey \1 index \1 name for members with a rank, sorted. It is carried everywhere the note hash is: rh on the relay, the guild ask's entries, a guild offer, a q = 2 request and a delta; rs beside hs / ns on a q = 1 request (kept on a request held for a /who). Absent means "cannot judge", as for notes, so a 0.8.1 peer is never re-pulled over ranks it cannot send.
  • Why a separate hash rather than rank folded into the note hash: a 0.8.1 client hashes notes alone, so folding rank in would make every 0.8.1 / 0.8.2 pair disagree on every round, and the stamp ordering would then pass the rankless copy back and forth.

Changed

  • detailsMatch(self, key, noteHash, rankHash) replaces the notes half of the "same copy" comparison, which was written out at six sites. Five call it now (answerGuildAsk, OnSisterRosterComm, serveRoster, takeGuildOffer, PullFromGuildOffers); BroadcastSisterRosters' suppression compares two stored hashes rather than a peer's against ours, and gained its rank half inline.
  • The relay suppression records ranks beside notes, so a rank-only change is re-relayed rather than swallowed.

Fixed

  • A roster served by a SAME-REALM sister member was always dropped as "we did not ask them" (TOGBankClassic's peer review GR-SAMEREALM-001, found by its four-client fleet on real AceComm). AceComm hands a receiver Ambiguate(sender, "none") -- bare for a player on our realm -- while every record the sync compares it with (pendingPull[key].peer, askedPeers, a roster charKey) is Name-Realm, and the comparison used CanonName, which never supplies a realm. My defect: the pull gate (PR-2, earlier the same day) made it fatal, because until then nothing checked who answered. Fixed at the boundary -- OnSisterPullComm, OnSisterRosterComm and OnSisterConfigComm normalize the sender once, on AceComm's contract that a bare addon-channel sender is on our realm -- and every sender comparison (askedFor, consumeAskedPeer, mutual, OnPeerVersion, ResolvePendingVerify, ResolveNameVerify, OnPeerUnreachable) goes through one senderKey, so a name that arrives from /who or VersionCheck in either spelling matches too. Also stored bare before this: rosterMeta.via, a refusal's peer, a held asker's sender.
  • Every same-realm member ranked "never reported" to VersionCheck and was never pulled from: VersionCheck keys a peer by its arrival spelling (bare for our realm) and PeerLibraryVersion looked it up as Name-Realm. It now tries the bare form for a name on our realm, and only for our realm.
  • Why no spec saw either: Tests/env_e2e.lua delivered every message with the sender as Name-Realm, the one spelling a real client never gives a same-realm sender. It now delivers it bare (arrivalName); with only the comparison fixes in place, 20 e2e scenarios went red under that change, which is how the stored-bare fields above were found. The harness's own Ambiguate returns the full name for "none" whatever the realm, filed with WoWAPITesting. NOT verified in game.
  • A delta is verified on notes and ranks, not only membership. r = 2 now carries nh and rh, and takeServedRoster checks both over the result before storing it; a mismatch asks for the full roster, as a membership mismatch always did. The base a delta is computed from is snapshotted when the MEMBERSHIP hash changes, so it need not hold the notes the taker holds: a taker whose copy differed only in notes (or now ranks) from that snapshot was sent an empty delta, verified it on membership, stored the same copy and asked again every round. Found by reading serveRoster while adding the rank hash; the rank case is the one the new e2e scenario drives (a copy saved before ranks: empty delta, refused, one full pull). The note case takes the same code path and was not separately reproduced.

Tests

  • hash_spec: GetRosterRankHash -- shape, order, independence from membership and notes, movement on a promotion, a renamed rank and a rank on another member, home rows and fed rosters agreeing, and the free-form rank.
  • sistersync_spec: a relay whose ranks differ is a miss; our relay and request carry rh; a rank-only change is re-relayed; the served wire carries rn / ri and never rank.
  • e2e_spec: rank crosses on a pull; a returning player's rankless saved copy gets ranks from a guildmate (empty delta refused, then a full pull, nothing sent to the sister guild) and persists them; a promotion crosses as a verified delta with the membership hash unchanged; then "no change". The guild-first scenarios' saved copies now carry the harness's rank, because a copy saved by this version does.
  • sistersync_spec: a same-realm peer VersionCheck recorded bare is found for Name-OurRealm and bare, and never for another realm.
  • Harness pin 34107ac -> acac151. Adopts f5d2383, Ambiguate's context table (our contract f578e2bf): env_e2e.lua's arrivalName now calls the harness's Ambiguate(name, "none") instead of returning the bare name by hand. The move turned three timer-driven specs red, and the cause is harness-side and MEASURED: a 300 s ticker due at 10639.000000000 ran with GetTime() = 10638.999999996, so the library's own (GetTime() - last) >= SISTER_PULL_INTERVAL pacing read the round as too soon. Filed as contract adcdd67f; the harness fixed it the same hour in 98c7d86 (a timer runs at max(clock, at) and the clock never moves back), and the local stand-in written for the gap was deleted on adopting it. The three specs are green on the harness's own timers with no margin added.
  • installShowOfflineEcho is retired -- the local copy of the show-offline setter's GUILD_ROSTER_UPDATE edge that the harness shipped as contract 7 long ago. It had stayed because the harness's edge reaches only env.frames widget-layer frames and nothing here loaded that layer. The three feedback-loop specs in showoffline_spec.lua now load the library on the widget layer, deliver events with frames.fireEvent, count writes by wrapping the real setter, and the third asserts the harness's setter really reaches the library's frame, so the other two cannot pass on a mechanism that never fired.
  • 722 specs green; coverage 100% on the library, env_guild.lua and env_e2e.lua.

Older versions (0.8.1 and earlier) are in CHANGELOG_ARCHIVE.md.