GuildRoster-v0.7.0
What's new
Changelog
All notable changes to LibGuildRoster-1.0 are documented here.
0.7.0 -- the sister-guild sync moves into the library
The LibStub MINOR moves 17 -> 18. Additive on the API side -- nothing existing changed shape -- with the behaviour changes a consumer can notice listed under Changed. The standalone addon gains a second hard dependency (LibAceGUIWidgets) and its first SavedVariables table; an embedding consumer should read the two notes at the end of Added.
Why. TOGProfessionMaster carried the whole cross-guild feature itself: the
officer-edited list of allied guilds, the gossip that spreads it through the
home guild, the DeltaSync RosterSync host that pulls a sister guild's roster,
the persisted copy and the login re-feed. TOGTools and TOGBankClassic wanted the
same rosters, and three addons each running that would be three lists that can
disagree and three feeders doing wipe-and-replace into one store. The user's
direction (TOGTools' docs/DEPENDENCY_CONTRACTS.md section 3, 2026-09-13):
"i need that and i need it to not step on each other" / "probably best to do
it in the library", and on the 2026-09-13 review of the draft: "what it
should have been asking you to do was a sister guild functionality of having
sister guild roster info like what we did with TOGPM. we need to be able to
serve that to multipole addons". So it is a port of TOGPM's working code
(Scanner.lua:200-281, TOGProfessionMaster.lua:2052-2400), owned once, read
by every consumer.
Added
The sister-guild list, in the library.
lib:SetSisterGuildNames(names)(officer-only, decided bylib:IsOfficer(); takes an array or newline-separated text; trims, de-duplicates case-insensitively, stamps server time, tears down any guild dropped from the list, and gossips the result),lib:GetSisterGuildNames()(a sorted copy -- the one read TOGTools asked for, so a consumer can tell "nothing configured" from "configured, roster not pulled yet"),lib:GetSisterGuildsTs(),lib:GetSisterGuildKeys()andlib:IsSisterGuildKey(key). The last is case-insensitive on purpose: the list is typed by an officer and every key on the wire is spelled the way the provider's client spells its own guild, and one capital letter must not make a whole guild "unlisted". It is the single gate every accept, persist, relay and pull path calls.Config gossip on GUILD (prefix
LibGRxcfg): on change, ~20 s after the roster is ready, and every ~12 minutes; last-writer-wins by the origin stamp, adopted without re-stamping and without re-broadcasting; a member holding nothing (stamp 0) never broadcasts, so an empty list cannot win the race. Own echo dropped. Fires the newOnSisterConfigChanged(names, ts, source)callback,sourcebeing"local"or the sender.Roster relay on GUILD (prefix
LibGRxrst): every held listed roster, ~35 s after ready and every 5 minutes, with a recently-seen-by-hash suppression (270 s) so about one holder relays per interval and the duty rotates. A relay carries who the relayer saw online recently, so one proven sighting reaches every member's presence overlay. Received relays are gated by the list, skipped when the membership hash already matches, and never re-broadcast.Persistence and the login re-feed.
## SavedVariables: LibGuildRosterDBon all six TOCs. Keyed by HOME guild, because the list is a statement about which guilds this guild federates with. A fed roster for a listed guild is snapshotted whoever fed it (RosterSync, the relay, or a consumer still running its own re-feed); an unlisted guild is never persisted; a persisted roster whose guild left the list is forgotten -- under whatever spelling the wire gave it, because the teardown sweeps the held and persisted keys as well as the list's own (found on the release re-read: a copy whose key differed only in case from the dropped entry survived until the next login's re-feed forgot it). Re-fed at roster-ready, beforeOnRosterReadyfires, so a consumer reacting to ready already sees them.lib:RefeedSisterRosters(),lib:PersistSisterRoster(key)andlib:GetSisterDb()are public.RemoveSisterRosternow also drops the persisted copy.The pull, as the library's own two messages over WHISPER (prefix
LibGRxpull): a request carrying every sister hash we hold, and an answer that is either "no change" or the provider's home roster (name, class, level -- rank and notes deliberately excluded) with the provider's own name and a server-time stamp.lib:PullSisterRoster(peer)is the manual bootstrap (what/togpm pullrosterbecomes);lib:RequestSisterRosters()is one automatic round, run every 5 minutes: each listed guild is pulled from its freshest known-online member, never from a member with no presence stamp. A served roster for an unlisted guild is not stored; one whose provider is not in it is dropped (the cheap trust check DeltaSync made); a listed one is stored, persisted, its provider stamped online, relayed to the home guild ten seconds later, and announced with the newOnSisterRosterUpdated(guildKey, source)callback ("pull"or"relay"). An unanswered pull (30 s) clears that peer's stamp so the next round asks somebody else. Serving is accept-all -- a roster is public to anyone who joins the guild -- but every message is learned from: a sister member asking us is provably online, and an unknown requester is pulled from once while a listed roster is still missing, because they are online right now and that is the one thing a blind whisper cannot know.lib:IsSisterSyncAvailable()is true once the roster is ready in a guild. Only a stabilized roster is served. After a guild change the home key resolves the momentGetGuildInfoanswers, while the login stream is still filling the roster in; a partial roster served in that window is a partial hash that every sister client would compare against and re-pull forever. Found on the release re-read, fixed and specced: a request that arrives mid-stream gets silence, which costs the asker one retry.Not DeltaSync, and this is a change from the first draft of the release. The pull was first put on a DeltaSync RosterSync host; the user asked, on seeing the TOC, "since we're doing the sync through GR, do we need deltasync and acecommqueue or is that overkill?" It was: DeltaSync added a second dependency chain (DeltaSync -> AceCommQueue), seven addon-message prefixes for a host that used two, and -- the real cost -- a provider that had to have DeltaSync installed before it could answer. Anyone running this library can serve now, and nothing beyond Ace3 is needed for the sync.
The window --
/gr. The user, on seeing the slash commands: "i feel like we should have a little UI like what we did with version check ... then /gr can just open the UI, most folks wont need to get to it, leave the commands as backup." A LibAceGUIWidgetsClearFramein VersionCheck's shape: an "Add a sister guild" box (disabled, with a note, for anyone who is not an officer or the guild master -- the library refuses regardless), a "Pull a roster from" box, aRowListof the configured guilds (Guild / Members / Online / State, sorted by guild, a remove icon officers see), a right-click menu offering Pull from each member seen online recently -- so the bootstrap is a click once anyone has been sighted -- and Remove, and a Sync now button on the bottom bar with the widget library's cooldown. Built on first open; repaints onOnSisterConfigChanged,OnSisterRosterUpdatedandOnRosterHashChanged; sends nothing by itself./guildrosterwith no argument opens it too. LibAceGUIWidgets is now a TOC dependency of the standalone addon (all six TOCs, and.pkgmeta'srequired-dependencies, whichtoc_spec.luanow checks); an embedded copy without it prints one line and the slash commands still work.lib:GetSisterStatus()is the model both the window and/guildroster sistersdraw from;lib:BuildSisterWindowRows(),lib:SisterRowMenuItems()andlib:ToggleSisterWindow()are public for consumers and the specs./guildroster(aliases/libgr,/gr) -- the text backup to the window. Added after the sync was seen working in game, on the user's words: "we need a way to configure the 'sister' guild somehow into guild roster. it needs to only be configurable by an officer or GM."sisterslists the configured guilds with their state (not pulled yet / N members, M seen online) and whether the pull path is up;sisters add <Guild>,sisters remove <Guild>(case-insensitive) andsisters clearedit the list and are refused with a plain reason for anyone without the officer-note permission -- the gate isSetSisterGuildNames' own,lib:IsOfficer(), which the guild master always passes;pull <Name-Realm>is the manual bootstrap;syncruns the gossip, the relay and one pull round now. The body is public aslib:HandleSlash(msg)so a consumer can route its own command to it. Neither spelling is taken by the client or any addon in this install. Seven examples inTests/sistersync_spec.lua; suite 555 -> 562.Two notes for a consumer that EMBEDS the library rather than depending on the standalone addon. (1) The library reads and writes the global
LibGuildRosterDBand nothing else; only a TOC that declares it as## SavedVariables:makes it survive a logout, and the six standalone TOCs do. An embedded copy in an addon whose TOC does not declare it holds the list and the rosters in memory for the session -- the sync still runs, the gossip restores the list from a guildmate at the next login, and the first roster waits for the first relay or pull -- which is the pre-MINOR-18 behaviour, not an error. (2) The window needs LibAceGUIWidgets; without itToggleSisterWindowreturns nil,/grprints the slash usage instead, and everything else works. Both are the same recommendation: an addon that wants the sync should take the standalone addon as a dependency (## Dependencies: GuildRoster, CurseForge sluglibguildroster) instead of embedding, because the standalone TOCs declare both.
Changed
PRESENCE_TTLis 900 s, was 120. The old value was sized for a consumer's/whopoll that no consumer ever ran. Presence now comes from proven sightings on the wire, which are sparser, and the window has to outlast the pull interval or the provider of one pull is stale before the next. A consumer readingGetOnlineMembersScopedon a sister roster sees a longer tail after a logoff than before.- The library owns persistence, so a consumer's own re-feed is now a second
feeder into the same store. Harmless --
SetSisterRosteris wipe-and-replace and the library snapshots whatever lands -- but the migration named in TOGTools' contract (TOGPM dropping its copy and importing itsprofile.sisterGuildsonce) is what removes the duplicate. docs/HEALTHCHECK.md'sminorrow reads 18.
Not built, and not to be built
- No
/whoof any kind. The contract as written asked for "/who-driven peer discovery" and "MarkOnline fed from the /who poll". Neither exists in TOGPM (its comment says discovery "comes in a later step") and neither can exist as described:C_FriendList.SendWhorequires a hardware event on every client --HasRestrictions = truein the Classic EraFriendListDocumentation.lua, the 8.2.5 patch note, and FastGuildInvite's measured "one query per click" -- so no timer can call it. A draft of this release carried a passiveWHO_LIST_UPDATElistener that only read answers the player's own/whohad produced; the user stopped it: "what are you trying to do with /who, you shouldn't". Removed entirely; the library has noC_FriendListreference. Bootstrap needs one proven sighting per sister guild, from either side: a member of theirs pulling from us, or a consumer handingPullSisterRostera name. - No blind whispers. A whisper to an offline player prints "No player named 'X' is currently playing." in the player's chat, and FastGuildInvite's presence module records the fleet rule: nothing drives those automatically. Hence "freshest known-online member, never an unstamped one" above.
- No DeltaSync involvement at all -- see the pull above. (A draft carried
## OptionalDeps: DeltaSyncfor an hour; DeltaSync's own TOC hard-depends on GuildRoster, so that line was a dependency cycle before it was anything else.)
Tests
Tests/sistersync_spec.lua, 60 examples, all through the REAL AceComm-3.0 (chunking, spool, dispatch) and the REAL AceSerializer-3.0. One library instance plays both ends: what it sent is replayed into AceComm's own frame under a guildmate's name (guild.replaySent), so a receiver sees the bytes a sender produced, control bytes and all -- one example round-trips a 60-member roster through a 12-chunk multipart send, and the pull examples serve and take rosters the same way.Tests/zz_window_spec.lua, 14 examples, builds the real window on the real AceGUI and the installed LibAceGUIWidgets (the harness's widget layer, opted into last, in the same order VersionCheck's suite learned the hard way). With the four examples for health-check command 7 (below), suite 501 -> 579; coverage 1473/1473 on the library and 140/140 onTests/env_guild.lua.zz_window_spec.luamust run last, and thezz_prefix is whatrun.luasorts on: the slash spec's bare/guildrosteropens the window, and if the widget layer was installed by an earlier file and then reset, that open dies inside LibAceGUIWidgets (grip:CreateTextureon a hollow frame). Measured while preparing this release by passing the files tocoverage.luain the wrong order: 578/579, 99.86%. Pass them alphabetically.docs/HEALTHCHECK.mdgains command 7, which separates the three ways the sync fails silently -- the list has not arrived, a listed guild was never pulled, the pull path is down -- andTests/healthcheck_spec.luaruns it like the other six.Tests/env_guild.lualoads AceComm-3.0 and AceSerializer-3.0 for every spec, so each login build takes the branch a live client takes, and gained three things the wire needed: a ChatThrottleLib reset per test (CTL is one global for the suite and queues what it has no bandwidth for -- a broadcast from five tests earlier was adopted by a later test through that queue, and a send made at "35 s" was still queued at "336 s" behind a previous file's backlog); a sweep of AceComm's registry for stale library instances, found by theownertag on the library's comm object, because specs load the library by three routes and no single handle knows them all; andwithoutLib/loadDeltaSyncfor the absent-library and real-DeltaSync branches.- Every timestamp in the sync uses
GetTime, including the relay suppression TOGPM stamped with server time. It is a local "how long ago", the frame clock cannot jump, and it is the clockMarkOnlinealready used. Found because the harness moves server time by a wholeadvanceTimeup front, so a ticker firing "at 35 s" inside a 270 s advance read 270 s of server time and relayed early -- a modelling artefact, but the frame clock is right anyway.
0.6.1 -- the Whitemane build is declared again
No Lua behaviour changed and the LibStub MINOR stays at 17. The fix is a
TOC line; the MINOR rule counts behaviour, bug fixes and API surface in the
library file, and none of those moved. A consequence worth stating: 0.6.0 and
0.6.1 are indistinguishable at runtime -- LibStub.minors["LibGuildRoster-1.0"]
reads 17 for both -- so the healthcheck /dump cannot tell you which is
loaded. GetAddOnMetadata("GuildRoster", "Version") can, in a packaged install.
Fixed
GuildRoster_Wrath.tocdeclares30405, 38000, 38002; 0.6.0 shipped it as30405, 38002and that dropped Whitemane. 38000 is the interface number the Whitemane private server reports -- the operator's own confirmation, recorded in VersionCheck-1.0'sCHANGELOG.mdunder "38000 stays" after a fleet review there had "corrected" it to 38002 and been reversed. It appears in no Blizzard product list by design, which is why a pass that checks numbers against Blizzard's sources deletes it; 0.6.0 never had it, because the 2026-09-10 change read the number off other addons'_Wrath.tocfiles and took the one the maintained ones carry.The cost: on Whitemane the library was flagged out of date and, with "Load out of date AddOns" unticked (the client default), did not load. VersionCheck- 1.0 hard-depends on this library (
## Dependencies: Ace3, GuildRoster, LibAceGUIWidgets) and keeps 38000 for exactly that server, so it failed at load with a missing dependency -- the library the operator pinned to Whitemane was the one addon in its own chain not declaring Whitemane. Found by Peer Review on the 0.6.0 self-audit thread, not by a player.38002 is kept alongside it: the number is real (DBM, WeakAuras, BasicMinimap, AllTheThings and DeltaSync all declare it) and multi-number
## Interface:lines are established client behaviour. No client on this box reports either number, so the "3.8.x" label the 0.6.0 notes gave 38002 is withdrawn from README, the CurseForge page, CLAUDE.md andtoc_spec.lua's flavour column. It was decoded from the number by analogy with 30405 = 3.4.5, not read from a client, and the self-audit had already named it the claim it was least sure of. The docs now say what is known: 38000 is Whitemane, 38002 is what the major addons ship.
Changed
Tests/toc_spec.luanow reads the three places that RESTATE the interface numbers and fails if any disagrees with the TOCs: the README's "hard dependency" table, the same table indocs/Curseforge_Description.html, and the parenthesised list in CLAUDE.md's sync rule. Same idiom ashealthcheck_spec.luareadingdocs/HEALTHCHECK.md. Self-audit finding 2: the numbers lived in four places with nothing tying them together, and the 38000 fix above was the first demonstration -- the TOC and the spec's table moved, the suite stayed green, and all three doc sites would have kept30405, 38002. Built before the docs were touched and watched go red on exactly the Wrath row in all three files (positive control), then green once they were updated. Suite 498 -> 501.The "only TOC files in the repo root" test is renamed to what it checks. It probes a closed list of ten known suffixes, so a stray
GuildRoster_Anniversary.tocis not caught and would ship. The old name claimed the directory was enumerated; the new one says "no KNOWN-SUFFIX sibling that is not in the table (an unknown suffix is NOT caught)" and the comment says why (io.popenis cmd.exe on Windows). Self-audit finding 1, Peer Review's remedy: a test whose name overstates its reach is the defect, the reach itself is acceptable.SetAltGroup's re-parenting guard now checkscanonNameexplicitly (LibGuildRoster-1.0.lua, theif canonName and previousOwner ...line in the re-parenting loop). The language server flaggedcanonNameas possibly nil at the table writes below it, which would raise "table index is nil". Traced: it cannot be nil there -- the loop walks an array holding only names that already passedCanonName, which is idempotent, and even if it were nil,previousOwnerwould be nil and the block would not run. The guard puts that invariant in the code instead of in a comment so a future edit to either half cannot silently break it. Suite 498 passed, 0 failed; coverage 935/935 -- the condition sits on an existing line, so no new line to cover.
Documentation
- The CurseForge page's 0.6.0 entry used a fourth grouping bucket, "For developers", which the player-facing rule (New / Changed / Fixed, three only) forbids. Folded into Changed. Found by the session's own self-audit. The page is not in the packaged zip, so the 0.6.0 tag is unaffected.
0.6.0 -- alt groups, two more flavours, and the packaging rules are checked
The LibStub MINOR moves 16 -> 17. Purely additive -- nothing existing changed shape, and a consumer that ignores the new methods is unaffected.
Backward compatible in full, on the TOC side as well: every interface
number that worked before is still declared -- 30405 and 120007 are kept
alongside the new ones rather than replaced -- so no client that loaded 0.5.1
stops loading. Multiple numbers on one ## Interface: line is long-standing
client behaviour, not a new trick: BugSack declares twelve on a single TOC and
loads on this Classic Era client today.
Added
Alt groups. Eight methods, all new, for recording and querying which characters belong to one player's account:
lib:SetAltGroup(ownerName, altNames[, meta]) -- wipe-and-replace, sorted lib:RemoveAltGroup(ownerName) lib:GetAltGroup(ownerName) -- sorted array, or nil lib:GetAltGroupMeta(ownerName) -- the feeder's canon, verbatim lib:GetAltOwner(altName) -- the key it is filed under lib:IsSameAccount(nameA, nameB) lib:GetKnownAltOwners() -- sorted array lib:IsAltOfRosterMember(name[, rosterKey])IsAltOfRosterMemberis the one that earns its keep. A bank alt is usually not in the guild, so any visibility sweep keyed on roster membership deletes it unless something vouches for its owner. It works across sister rosters too -- an alt of a member of a sister guild is still an alt of somebody you know -- and takes an optionalrosterKeyto scope the question. It answers false for a character that is in the roster on its own account: the question is whether somebody else vouches.The owner key is a key, not a "main". It is whichever character the consumer filed the group under. This library has no way to know which character a player mains and deliberately does not claim to; a consumer wanting a display name picks one itself.
member.guild-- which guild a character is in, carried ON the character. An alt group is account state and spans guilds, so a consumer drawing a guild view needs the guild per character. That is per-character data, so it lives in the member table alongsidename,classand the rest: the home guild key for your own guild's members, the sister-roster key for anyone fed throughSetSisterRoster. No splitting call, no parallel index, and nothing that can drift from the roster it describes -- it is written when the record is created and a guild change wipes and rebuilds.A character with no member record is UNPLACEABLE, not unguilded. This library knows only the rosters it has been given, so an alt in a guild nobody fed us looks exactly like one in no guild at all; a consumer hiding those hides both. Feeding the missing roster gives that same character a record with no other change, which is why absence could never have meant unguilded. There is no client API answering "what guild is this arbitrary character in", so this is a limit rather than an omission.
A provenance canon, so several feeders in one client cannot silently clobber each other. Requested by FastGuildInvite (
docs/LIBRARY_CONTRACTS.mdrequest 2, answered SHIPPED).SetAltGrouptakes an optional third argument -- stored verbatim, never read into, and read back throughGetAltGroupMeta:{ source = "MyAddon", setAt = <datestamp>, hash = <computed at save> }The alt store is deliberately shared across every addon in the client, so several can write it; without a canon no feeder can tell its own stale data from another addon's fresher claim.
The three fields follow DeltaSync's "canonical hashes -- compute once, at save, and never again".
setAtgoes inside the hashed input, which is what makes an equal hash mean the same publish event rather than merely the same names, and is why it is not a second identity channel. The library computes no hash of its own, ever: a hash it recomputed would be its opinion of the author's version, and then nobody is authoritative. A spec plants a deliberately wrong hash and asserts the wrong value survives every read path, because comparing against the correct one would pass even if we quietly recomputed it.SetAltGrouptherefore has four returns, and the difference matters to a feeder:true-- stored.true, "unchanged"-- the samehash, so the same publish event. Nothing was written: no wipe, no meta replace, no reverse-index churn. This is the return that stops peers re-syncing data they already hold.false, "stale"-- an oldersetAtthan the standing record. Refused, and not an error: somebody fresher got there first.false-- bad input. The alt list was not a table, or the owner name could not be canonicalized. That one is a bug in the caller.
Identity and ordering are separate questions and are answered separately. The hash says whether two versions differ; it cannot say which is newer, and
setAtis the only thing that can. Rule 7 of that canon puts the ordering decision at the apply step, andSetAltGroupis the apply step for alt data -- so the rule lives in the library once, for every consuming addon, instead of each inventing one and drifting.Both checks require both sides to carry the fields, so a caller passing no
meta-- or a pre-canon one -- gets exactly the last-write-wins behaviour it always had. Mixed adoption is safe and nothing has to move at once.One divergence from the sister-roster precedent, and it bites a feeder that assumes otherwise:
metafollows the wipe. Omitting it clears the stored canon rather than preserving it, because the group was just replaced wholesale and a surviving stamp would credit the new group to whoever fed the old one. Pass it on every call, not just the first.
Changed
GuildRoster_Wrath.tocnow declares30405, 38002. 38002 is the Anniversary Wrath-family build. It belongs on the existing_WrathTOC rather than in a seventh file, which is where every actively-maintained addon in this install that carries it puts it -- DBM (DBM-Core_Wrath.toc), WeakAuras (WeakAuras_Wrath.toc), BasicMinimap and AllTheThings -- and the same seven numbers are what DeltaSync already declares. Nothing in the library needed changing for it.IS_RETAILis the only flavour branch in the file and it keys onWOW_PROJECT_ID == WOW_PROJECT_MAINLINE, so 38002 takes the identical non-retail path that 30405 has always taken.GuildRoster_Mainline.tocnow declares120007, 120100, and that one was a live defect..build.infoat the WoW root reports the installed retail client as12.1.0.69587-- interface 120100. The TOC said120007and nothing else, so on current retail the library was already flagged out of date and would not load without "Load out of date AddOns" ticked.
Why both landed now: VersionCheck-1.0 is taking this library as a hard
dependency, which collapses its supported flavour set to the intersection with
ours -- so a stale number here silently costs that addon a whole client. It asked
for seven numbers; six were already covered, because there have been six
per-flavour TOCs since 0.1.0 and the request had read GuildRoster.toc alone.
The retail gap was ours, and an outside consumer found it before we did.
Fixed before release -- all three found by peer review
Two owners claiming one character left the forward map and the reverse index disagreeing permanently. A client broadcasts only its own key but stores one group per broadcaster it hears from, so the collision lands on every receiver rather than on the sender. The retire loop only ever inspected the writing owner's previous group, so Carol could go on listing Bob while
GetAltOwner("Bob")answered Alice -- and the== ownerguard, which is correct and stays, meant no later update reconciled them.IsSameAccountandIsAltOfRosterMemberthen answered from whichever structure they happened to read: a lossy index producing a confidently wrong answer, not an absent one.Claiming a name another owner still lists now drops it from that owner's group too -- last writer wins the character, not merely the index. The previous owner's array is replaced, never spliced in place, because
GetAltGrouphands back the internal table and a consumer may be holding it.Known cost:
GetAltGroup(previousOwner)no longer returns exactly what that broadcaster said. That is a real departure from "the stored group is the last caller's whole picture", and it is the price of the two structures agreeing by construction.An unstamped write disarmed the staleness check for the next stamped one. Omitting
metaclears the stored canon by design, but the ordering check read that cleared value, found nothing to compare against, and accepted a record it had already superseded. One write from a not-yet-adopted addon therefore removed the protection for every adopted one -- guaranteed to be reachable during rollout, since adoption is staggered by definition.Ordering now compares against an internal high-water mark (the highest
setAtever accepted for that owner) which survives the clear. It is a bare number, never the caller's table: remembering how far we have got is the library's, attributing a version stays the caller's.Known cost:
RemoveAltGroupdrops the high-water too, so a remove-then-re-add can land an older record. Deliberate -- removing is an explicit consumer decision, and keeping the mark would make an owner permanently un-refeedable from an archive stamped before the removal.Two spellings of one character defeated the re-parent above. The fix for the first item matched the previous owner with an exact lookup, while every read resolves through both spellings -- a bare name and its local-realm qualified form are one character on a read, and
CanonNamedeliberately preserves both as distinct keys. So when one broadcaster claimedBoband another claimedBob-YourRealm, the reconciliation found nothing to retire, both index entries stood, andGetAltOwneranswered a different owner depending on which spelling you asked with -- from the very accessor the first fix existed to make consistent.The re-parent now resolves the previous owner through both forms, drops either spelling from that owner's group, and retires the other spelling's index entry as well. Rebuilding the group alone was not enough: leaving the old spelling pointing at the old owner is the same defect facing the other way.
The boundary is unchanged and deliberate: a bare name and a name qualified with any other realm are still different characters and are never merged.
Resolving which stored entry to retire is a read of state the library already holds -- it never invents a realm and never changes what is stored -- so this does not weaken the rule that writes never invent a realm.
Ported, with two deliberate corrections
This API is a port of the alt-group code that has been running in TOGProfessionMaster, not a fresh design. Two of its properties came straight across, and two are corrected on the way:
Wipe, never merge.
SetAltGroupreplaces the owner's list wholesale. There is no "remove one alt" call, because a merging API has no correct way to say this character is no longer mine.The stored array is SORTED, and that is not cosmetic. Every downstream comparison depends on it -- a "did this group change" check walks two arrays element-wise, and a sync hash is computed over the array as given. Unsorted, two clients holding the identical set compare unequal, hash differently, and resync each other forever.
"Claims are keyed by the broadcaster, so there is no collision to refuse" came across and is FALSE on a receiver -- this is the second correction. It is true of what a client sends and false of what a client stores: a receiving client calls
SetAltGrouponce per broadcaster it hears from, which is the whole point of the table, so one client's store holds many owners and two of them can name the same character. The collision never happens on the send path and happens on every receiver. The re-parent above is what this library does about it, and thesetAtordering rule is what it does about the timestamps the ported code assumed it did not hold.Alt names go through
CanonName, notNormalizeName-- this is the first correction. An alt group is wire data by construction: one player's client telling every other client which characters it owns.NormalizeNameappends the receiver's realm to a bare name, so on a connected-realm cluster the identical broadcast becomes a different identity on every listener, with a different sorted array and a different hash, and the clients never converge.CanonNamecanonicalizes the representation and never invents a realm. The code this was ported from predatesCanonName(MINOR 11) and cannot have this right.A bare name therefore stays bare in storage. Roster comparisons still normalize, because "is this character in my roster" is a local question where the local realm is a correct inference -- that boundary is where the two name forms meet, and
IsAltOfRosterMemberhandles it.
Testing
The test environment is now measured too, and it was not at 100%. The coverage command in
CLAUDE.mdnames the library and the specs, soTests/env_guild.lua-- the layer every spec steers the client through -- had never been measured. Three lines were uncovered and all three were the same class of hazard: an untested guard that fails somewhere else. TheclassDisplay->classDisplayNametranslation is exercised by a spec now, and a positive control pins what breaking it costs -- the row reads back"Warrior", the harness's default, so a plausible class silently stands in for the one the spec asked for and the resulting failure reads as the library's fault rather than the environment's. The twoerror()guards on an unknown steering field and an unknown system-message kind are asserted directly; both exist so a typo fails loudly instead of reading back nil and turning a steered test vacuous, and each was controlled on its own break.Tests/toc_spec.lua-- the packaging rules are checked now, and they never were. 16 new assertions, 482 -> 498.CLAUDE.mdhas required that all six TOC files stay in sync since the library was split out, and nothing enforced it: no spec in this repo read a.tocor.pkgmetaat all. The cost is the retail number under Changed above -- stale, and caught by somebody else.What it pins: all six TOCs exist and no seventh has appeared; each declares exactly the interface numbers we have decided to ship; every directive except
Interfaceis byte-identical across all six, checked in both directions so a field present in only one file is caught; the file lists match and every listed file exists on disk; noLibStuborCallbackHandlerline has crept back in;## Version:is still the packager'sGuildRoster-v0.7.0token rather than a frozen number.It also reads
.pkgmeta, because that file fails silently and there was no other check:Testsanddocsmust be ignored as bare names (a trailing slash becomesdir//*and matches nothing, shipping the suite and the audit to players), and no list item may carry a trailing comment -- that leaves a stray unbalanced quote which breaks the packager's re-parse, copies every file nowhere, and uploads an empty archive while exiting 0. That one shipped two releases of another addon before anyone noticed.Both halves are positive-controlled, not merely green. The interface assertion was broken on purpose (
40402->40403) and the sync assertion by adding an## X-Websiteto one TOC; the run failed with exactly two failures naming the file and the directive, and both were reverted. The interface table is deliberately brittle for the same reasonhealthcheck_spec.luapins the MINOR: it records what we decided to ship, so changing one TOC without the others goes red.Suite 498 passed, 0 failed; coverage 935/935 executable lines (100.00%) across the library and the test environment.
Harness pin moved
1f8fe09->2406895. Three adoption-log entries landed in one push (secret values /GetPlayerInfoByGUID/frames.layerMismatch()/GetMouseButtonClicked(); Classic Era bank constants and a distinct-objectloadedCount(); the two nameless guild-invite refusals). Every one says "ADOPT: nothing" unless you use the named API, and this library uses none of them -- checked by reading, not inferred from the green run: nocanaccessvalue,issecretvalue,GetPlayerInfoByGUID,GetMouseButtonClicked,NUM_BANK*,loadedCount,retainDuring,markLoadedorclearLoadedanywhere outside the submodule, no bank code, no widgets, no invite classification. The library's own secret-value strategy is the lockdown flag (C_ChatInfo.InChatMessagingLockdown()), which never touches the payload and so never needs the new guards. Suite 498 passed, 0 failed on the new pin with no source or spec changes.The pin had been unmovable, and the blocker was not harness code. The submodule checkout carried a stale unmerged
.claude/settings.json-- Claude Code's permission allowlist, three index stages, noMERGE_HEAD, so a leftover from an earlier interrupted pull rather than a merge in progress. The local side held twenty auto-accumulated permission grants from sessions that had run inside the checkout; the harness side was the canonical file. Nothing else in the submodule was dirty. Resolved byfetchthenreset --hard 2406895, which discards only those grants and restores the checkout to exactly what the harness ships -- the state a submodule checkout is supposed to be in, and what the next pull would have done anyway. That is the opposite of authoring a harness change, which the one-way rule forbids.
Documentation
README.mdcarries the feed recipe: "Feeding alt groups with a DeltaSync canon". The save-time snippet (stampsetAtfirst, excludehashfrom its own input, then hash), what each of the four returns means and what a feeder should do about it, and the trap that a stale check protects the key you are writing, not one you areRemoveAltGroup-ing during an owner-key sweep.And the one a real consumer's first test run found: "if your owner key can move, use ONE rule in every store that holds it". FastGuildInvite derives its owner key from the group's contents (the alphabetically-first character), so the key moves when a player rolls a character sorting earlier. Their persisted store asked "did the old group name the new owner?" -- which is never true in exactly the case that matters, because the new owner did not exist as a key when the old group was filed. The stale entry survived, restore fed both, and each feed's sweep removed the other: the account landed under a different key every login depending on table-iteration order, presenting as "a group that keeps changing owner for no reason".
This library was never wrong and never saw an inconsistent state -- the damage was entirely inside their own store. It is documented here because their feed is the pattern the other adopters will copy, and because of the asymmetry that makes the local store the harder problem despite looking like the easier one:
GetAltOwnercan tell you who currently owns a name, and your own store cannot.A fifth health-check command: are the feeders stamping a canon?
docs/HEALTHCHECK.mdcommand 5 reports{ owners, canon, hashed }. An unstampedSetAltGroupsucceeds silently and is indistinguishable from a stamped one from the outside, so in a client where several addons adopt at different times this is the only way to see who has.canon < ownersis the mixed-adoption state (those groups get no identity check, so unchanged re-broadcasts are applied as fresh writes);hashed < canonmeans a feeder is sendingsetAtwithout a hash, which is ordering without identity.docs/REQUESTS.mdis nowdocs/LIBRARY_CONTRACTS.md. Dev-only, so no packaged file moved and no consumer has anything to change. Renamed to match the fleet-wide naming law for inbound contract files: an addon looking for the standard name found nothing in this repo, which is the same failure that created the board in the first place -- ClassicCalendar's request sat unread for six days in the harness's contract folder for want of somewhere obvious to put it. Done as agit mv, sogit log --followstill reaches the whole conversation.References in
CHANGELOG.md,docs/AUDIT.mdandTests/HARNESS_CONTRACT.mdstill name the old path and are deliberately left: all three are append-only records of what was true when they were written.The alt-group design review in
docs/AUDIT.mdis answered. It reviewed the proposal, not the code, because the user ruled on the design between the review being written and this session reading it -- so two of its four decisions no longer describe what shipped. The response takes each point against the code that exists, and concedes the reviewer's central catch:GetAltGroup's docstring justified returning the internal array by "consistency withGetAllMembers", andGetAllMembersbuilds a fresh array every call, so that justification was simply false. The behaviour is unchanged -- the real precedent isGetRoster-- but the docstring now says so, drops an unmeasured allocation claim, and records that nothing inside the library mutates a stored group in place.The developer / AI section grew from six items to eight, in both
README.mdand the CurseForge page, and the README was one behind already. It had five where the page had six -- the alt-group item had been added to one and not the other. Now aligned, and every addition comes from a real consumer question rather than an imagined one: all three were asked by VersionCheck-1.0 while taking this library as a hard dependency.- Item 3 (which name function) now states the two-ended rule -- the
SENDER qualifies, the RECEIVER canonicalizes -- and warns that
NormalizeNameis not pure: it appends a realm that isniluntil shortly after login, the library re-keys its own stores when that resolves, and it cannot re-key a consumer's. Persist its output only afterIsReady(). - Item 7: late callback registration.
OnRosterReadyfires once, from one site, and is never replayed; register thenif lib:IsReady() thenfill inline. Since build-once the cost of missing it is a window that is empty for the session, not for a beat. - Item 8:
SetSisterRosteris wipe-and-replace and there are already two feeders in the fleet. Two on oneguildKeyis last-writer-wins, presenting as members intermittently vanishing. Plus the realm-qualified feed requirement for a cross-realm sister guild.
- Item 3 (which name function) now states the two-ended rule -- the
SENDER qualifies, the RECEIVER canonicalizes -- and warns that
New subsection "Taking this library as a hard dependency", both files. States the intersection rule (a dependent addon loads only where both TOCs are current), lists the exact
## Interface:numbers per TOC so a consumer can check the intersection against their own, and points attoc_spec.luaas copyable. Written because the first consumer to take the dependency readGuildRoster.tocalone, concluded the library was Classic-Era-only, and filed a blocker that was five-sixths a misread -- and the remaining sixth was our genuinely stale retail number.The CurseForge page's Compatibility list names the Anniversary Wrath- family build under Wrath and says what was wrong on retail before this release.
CHANGELOG_ARCHIVE.mdexists, and 0.3.0 and older live there now. The packager publishes this whole file as the GitHub release body, and GitHub refuses one over 125,000 characters with HTTP 422 -- while the CurseForge upload succeeds, so the release looks published with half its notes refused. This file had reached 974 characters of headroom under the 120,000 working ceiling. Seventeen sections became four live (0.4.0 through 0.6.0 -- everything fromIsOfficerthrough build-once that consumers still reference) plus twelve archived; the split was made at the## 0.3.0heading by a script in binary mode, so every byte and every line ending of the moved sections is unchanged. The archive is excluded from the packaged zip -- it is reference material for GitHub, not for an addon folder -- and this file ends with a one-line pointer to it.
Not verified
- Neither new interface number has been loaded by a client.
.build.infolists onlywowandwow_classic_eraas installed on the development box, so there is no Anniversary client here to test 38002 against and no way to confirm that client reads_Wrath.toc. That placement rests on fifteen maintained addons agreeing, plus the fact that the library needs no code path it lacks. The 120100 half is better founded --.build.infonames the retail build directly.
0.5.1 -- stop writing the player's "Show Offline Members" setting
The LibStub MINOR moves 15 -> 16. One fix reported from the field, the TOC description correction that 0.5.0 shipped stale, and two defects the pre-release self-audit found in this release's own code.
Fixed
The library no longer writes
SetGuildRosterShowOfflinewhile resolving a new member's roster row, and that write was causing an FPS collapse for some players. Reported by a FastGuildInvite user while recruiting: 110 fps decaying to 20 over about seven minutes, cleared by/reload, and not reproducible by the addon's own author.SetGuildRosterShowOfflinefiresGUILD_ROSTER_UPDATE. That is engine-side: it is not in the client source and not in the generated API documentation, so it can only be found by measuring, which is what the reporting session did. The library used to bracket each roster scan by forcing the flag on and putting it straight back, which is two writes, therefore two events, per scan. Those events re-enter the handler, which scans again while a recently-joined member is still pending. An accepted guild invite is what starts it, so recruiting is exactly the activity that sustains it.It only affected players with "Show Offline Members" UNTICKED, because the bracket returned early when the flag was already on. Same code, same build, opposite behaviour, decided by a checkbox, which is the clearest possible sign that the setting should never have been load-bearing.
Nothing is lost by removing it: a member who has just accepted an invite is online by definition, and an online member's row is visible whatever the filter is set to. The one residual, stated rather than hidden, is that a joiner who logs off within 60 seconds on a client with the box unticked may keep a placeholder level of 1 until the next login.
The same write is gone from the login build and from the pre-login membership lookup as well, which is where most players were paying for it: once per login and per
/reload, on the largest scan the library does, for anyone with the box unticked.That was possible because the question the bracket existed to dodge finally got measured. On a live Classic Era client, 997-member guild, box unticked: the roster iteration returned 997 of 997 rows. It is not filtered. Blizzard's own guild UI reaches the same conclusion by construction --
GuildStatus_Updatereads both the total and online counts and chooses which to iterate to, which would be pointless if the data itself were filtered. On retail both accessors have no call sites left at all; that UI moved to the Communities API years ago.The retail case is now measured too, and it matches Classic. On a live retail client, 900-member guild, with the flag forced off and the write verified rather than assumed:
was=true, now=false, rows=900-- 900 of 900. Retail does not filter the iteration either, and the first return ofGetNumGuildMembersis the unfiltered total there as well.Two things that took three runs to establish, recorded because each one made a run inconclusive. The retail Communities "Show Offline Members" checkbox is decoupled from
GetGuildRosterShowOffline-- unticking it in the UI leaves the accessor readingtrue, because the modern guild UI keeps its own display filter and the legacy accessor has no call sites left in the retail client. So a measurement taken by clicking the box tests nothing; the flag has to be forced and read back.One spec is still pending, and no longer because the answer is unknown. The offline harness models retail as filtering, with no way for a consumer to turn that off, so asserting the measured behaviour would fail against the test environment rather than against the library. That is a harness change, raised as contract 8, and the spec says so instead of being quietly adjusted to pass.
The TOC description said the library used "wipe+rebuild semantics", which 0.5.0 made false. All six manifests now describe what it actually does. This is the line shown in the in-game AddOns list.
An error inside a roster scan reported the wrong line. Also found by the self-audit. Both scans were wrapped in a
pcallwhose result was re-raised immediately with nothing in between -- residue from the show-offline bracket, which had needed releasing on the error path. It caught an error only to rethrow the identical error one stack frame shallower, so the only effect was a truncated traceback: BugSack reported the re-raise line instead of the line that actually failed.The two sites were not the same shape, which is why they got different fixes.
ResolvePendingDetail's closure held a bare loop with no early return, so the whole wrapper is gone. The login build's closure is load-bearing -- its retry branch returns out of the closure rather than out of the function, so inlining it would have skipped the retry and left an empty roster nothing corrects -- and there the closure is kept and simply called directly.chatPatternsBuiltcould have reported a perfect score while ignoring a chat message type. Found by the pre-release self-audit, in code this same release added. The slot count was the literal7sitting beside a seven-entry positional table -- one constant in two places with nothing asserting they agree. Adding an eighth message and forgetting the literal would leave the loop reading slots 1..7, silently skipping the new one, and reporting7/7forever.That is the exact failure the counter exists to expose, reproduced one level up inside the counter, and it is worse than the
ipairsbug caught in the same block during 0.5.0:ipairsunder-reports and looks wrong, this over-reports and looks healthy.The count is now derived from a list of slot names rather than declared, and the probe table is keyed rather than positional, so the failure direction is inverted -- a slot with no matching entry reads
7/8, a visible false alarm instead of an invisible false all-clear. A new spec brackets it from the other end by removing everyERR_*global and asserting the count reaches zero, which is what makes the existing "7 of 7" assertion mean something.
Testing
354 specs pass, 0 fail, 1 pending; coverage unchanged at 100% (691/691). New
Tests/showoffline_spec.lua and Tests/healthcheck_spec.lua.
Verified in a live client before release, on a 997-member guild with "Show
Offline Members" unticked: MINOR 16 loaded, ready true, patterns 7/7, the
library's roster 997 against the client's own 997, the iteration returning 997
of 997 rows, the player round-tripping through IsInGuild and GetMember, and
an 8-digit roster hash. That is Classic Era; the retail case remains
unmeasured and its spec remains pending.
Four existing specs were deleted rather than repaired, and that is worth recording. They were parameterised over a model of the client that filters the roster iteration -- one was named "Classic (assuming it filters)", an assumption written into a spec name and then relied on as a safety property. They pinned the bracket's mechanism rather than any outcome a consumer can observe, so when the mechanism turned out to be unnecessary they had nothing left to assert. What replaced them checks the outcomes instead: the roster is complete, and the setting is never written.
The suite could not previously see this class of defect, and that is the more
important finding. The offline harness models SetGuildRosterShowOffline as a
plain setter, so the event it really fires did not exist in the test
environment, and no spec at any coverage level could have caught the loop. A
reference implementation now lives in Tests/env_guild.lua and the gap is
written up for the shared harness. Line coverage measures whether specs execute
the library's lines; it says nothing about whether the environment reproduces
the client's feedback.
Documentation
There is now an in-game health check:
docs/HEALTHCHECK.md. Three/dumpcommands, each under WoW's 255-character chat limit, that make the library's state visible after a login. A library has no UI, so "no Lua errors" is not evidence that it works -- and every serious defect this library has shipped failed silently: a locale whose pattern never matched, a roster that came up online-only, a callback that stopped firing, a payload that was dropped. None of them raised.They report whether the library is
ready, how many members it built against the client's own count, whether all seven chat patterns built, whether the player round-trips throughIsInGuild/GetMember, and whether the wire hash exists. Command 2 also doubles as the outstanding retail measurement: it counts the rows the roster iteration actually returns and prints the show-offline checkbox alongside them.The commands are executed by the offline suite.
Tests/healthcheck_spec.luareads the fenced blocks out of the document itself, compiles them, runs them against the harness and asserts the values are sane -- so the document is the single source of truth and a command that is edited into something broken, or that reads a field the library later renames, turns the suite red instead of failing in front of you at the moment you needed it. A diagnostic nobody tests rots invisibly, which is the same failure it exists to catch.Eight places still described the deleted show-offline bracket, or the deleted rebuild, as current behaviour. Peer review found three; sweeping for the claim rather than the symbol found five more. All are corrected.
The three named:
lib:OnPlayerLogin's docstring (which presented bracketing as the current, flavour-independent design, repeating the justification the 997/997 measurement disproved),lib:OnChatMsgSystem's comparison of its retail guard to the deleted path, and a spec comment inTests/buildonce_spec.luajustifying an assertion by the cost of a removed function.The five found afterwards: the file header's consumer-facing paragraph, which promised integrators that "every scan in here brackets itself"; a 27-line orphaned doc comment sitting directly above the "the bracket is gone, do not bring it back" block -- the deleted functions' own docstring, left in place, present tense, ending in a return contract for a function that no longer exists; a comment in
Tests/login_spec.lua; and two in the "the next rebuild will fix it" class --lib:RekeySisterRostersandlib:ComputeNormalizedNameboth justified skipping a home-roster re-key with "it is wiped and rebuilt on everyGUILD_ROSTER_UPDATE", which 0.5.0 made false. Those two are now precise instead: the login stream still rebuilds on every event until it stabilizes, and the realm resolves atPLAYER_LOGINbefore the server sends a row, so the window is still covered -- but the margin narrowed from "all session" to "the login stream", and that is now written down where the reasoning lives.The transferable half: a deleted symbol leaves dangling pointers, which a symbol sweep finds. A deleted guarantee leaves reassurances, which read as safety properties and are the ones a future session trusts. Sweep for both.
README.mdand the CurseForge description are current again. Both still carried the version and MINOR from 0.5.0, and both still told readers the library manages and restores their "Show Offline Members" setting -- README under a section heading promising exactly that. The CurseForge page gained the v0.5.1 entry it never had, and its stale "corrects it on the next roster rebuild" note about a joiner's placeholder level now states the real rule: one targeted row read, and it stays1for the session if that row never lands.
0.5.0 — build once: the roster is never rebuilt again
The LibStub MINOR moves 12 → 15. This is the largest behaviour change the library has had, and it removes a callback — read the Removed section before upgrading.
The problem, measured rather than argued
A player reported a micro-stutter every time a guildmate logged in or out. The cause was traced end to end, and none of it was guesswork:
GUILD_ROSTER_UPDATEis not a "something changed" signal. Its only payload iscanRequestRosterUpdate, a bool meaning "the server throttle has lifted, you may ask again" (GuildInfoDocumentation.lua:523-530, Classic Era tree). Answering it with a request is a self-sustaining loop whose period is the throttle.- Blizzard's own calendar runs exactly that loop.
Blizzard_Calendar.lua:4170-4177re-requests the roster from outside its ownIsShown()guard, registers the event inOnLoad, and never unregisters. - Measured live on a 978-member guild, with two instruments on two different
occasions. A counter on the event saw bursts of three every ~30 seconds
while completely idle, and up to nine on a single guildmate logging out,
reproduced three times. A
debugprofilestop()wrapper around the handler, fifteen minutes later after a/reload, timed 15 rebuilds at 3.22–47.04 ms each (mean 16.45 ms) and caught one guildmate's logoff as six rebuilds totalling 140.93 ms of main-thread work inside this library alone. A logoff burst is a range, six to nine, not a fixed number — the two captures were different logoffs on different characters, so the gap is variance rather than events going missing.
The event cannot be made rare: it comes from the client's own code and from every other addon that requests a roster. So the answer to it had to become free.
The result, measured in the same client after the change: a
post-stabilization GUILD_ROSTER_UPDATE costs 0.004–0.005 ms, against
3.22–47.04 ms before. On a 990-member guild the roster is complete
(GetAllMembers() = 990) with IsReady() true. Three to four orders of
magnitude, confirmed on real hardware rather than asserted from a call-count
spec.
IMPORTANT — if you consume this library, read this first
Everything is source-compatible — nothing renamed, nothing errors. But two changes are silent: your addon keeps running and quietly stops doing something.
OnMemberLevelChangednever fires again. See Removed.OnRosterUpdatedfires only during the login stream. If you used it as "the roster changed", you go deaf after login.
Both have working replacements and neither produces a warning. The full
upgrade table is in README.md.
Changed
The roster is built ONCE, during the login stream, and maintained in place from events for the rest of the session. After stabilization
GUILD_ROSTER_UPDATEreads no roster rows, allocates nothing, touches the show-offline setting not at all, and fires no callbacks. Verified by countingGetGuildRosterInfocalls, not by timing.Membership now moves through the chat events that actually carry it. The "has joined the guild" branch inserts the member immediately rather than firing
OnMemberJoinedand waiting for a rebuild to add them — under build-once that rebuild never comes, soIsInGuildwould have answeredfalsefor the rest of the session about someone chat had just proved was in the guild.OnRosterHashChangednow fires from the join and leave branches, so a sister client still learns to re-pull — and sooner than before, since the change is announced when it happens instead of at the next rebuild.A joiner's remaining fields arrive via one targeted row read. Chat carries only a name, so the member is inserted with what is proven (in the guild, and online — you must be logged in to accept an invite) and the next
GUILD_ROSTER_UPDATEreads the rows once to fill in class, rank and level. It costs ~1.3 ms on a 978-member roster against ~27 ms for the rebuild it replaces, runs only while a join is outstanding, and gives up after 60 s if the server never produces a row. Presence is deliberately not taken from that row.OnMemberRankChangedis now sourced from the promote/demote system messages (ERR_GUILD_PROMOTE_SSS/ERR_GUILD_DEMOTE_SSS) instead of the rebuild diff. The message names the new rank, not its index, so the library translates through arankName → rankIndexmap learned free from the roster rows it already reads.Known gap, stated rather than hidden: a rank nobody currently holds is not in that map, so a promotion into an empty rank cannot be translated. The library updates
member.rankName(which came from the server and is correct) and stays silent on the callback rather than inventing an index.The
recentlyLeft/recentlyOnlineTTL prunes moved to the write sites. They ran inside the rebuild, which was the one pass guaranteed to happen often; without relocation both tables would have grown for the entire session.OnRosterUpdatednow fires only during the login stream, because that is the only time a full rebuild happens. It is unchanged in meaning — "a full rebuild completed" — but a consumer reading it as the general "the roster changed" hook goes permanently deaf after login. Nothing warns you; this is the second of the two silent upgrade hazards in this release. Use the per-member callbacks, which stay live all session.OnRosterHashChangednow fires from the join and leave branches rather than from the post-stabilization rebuild, so a sister client learns to re-pull when the change happens instead of at the next rebuild. No signature change.
Removed
OnMemberLevelChangedno longer fires. It has no event source. It was produced only by the post-rebuild diff. UnlikeOnMemberOnlineandOnMemberRankChangedthere is nowhere to move it to: noCHAT_MSG_SYSTEMmessage announces a guildmate levelling.GUILD_NEWS_FORMAT6("%s has reached level %d!") is a Guild News UI feed from Cataclysm's guild system, which Classic Era does not have. Seeing a level change requires re-reading the whole roster — precisely the work this release exists to stop doing.The cost is real and named: TOGTools' Gratz announces guild level-ups off this callback and will go silent. No error, no warning — the callback simply never fires. This was weighed against 140.93 ms of main-thread stutter per guildmate logoff and accepted deliberately.
member.levelis still populated from the login build and readable throughGetMember; only the notification is gone.The post-rebuild diff was deleted outright, rather than left in place unreachable. Dead code that looks like a live callback source is how a future reader concludes a callback still fires when it cannot.
Fixed
Positional format specifiers (
%1$s) are now understood, and the kicked-from-guild message finally works on German clients. Blizzard writes some locales' strings positionally where their grammar needs the arguments in a different order — deDE'sERR_GUILD_REMOVE_SSis"%1$s wurde von %2$s aus der Gilde gekickt.". Neither the pattern builder nor the needle builder handled that, and the failure was total, not partial: the pattern contained a literal%1$sthat no rendered message ever holds, and the needle pre-filter became the entire format string, so it rejected every line before the pattern was even tried. A German player kicked from the guild produced noOnMemberLeft, on every version of this library that has shipped. Found by adding the promote/demote strings, where deDE is positional too.The fix resolves the declared argument, not the capture position — the whole reason a locale reorders is that capture #1 may not be argument #1, so reading positionally would have named the officer who kicked someone instead of the player who was kicked. Scope, stated exactly: it repairs deDE kicks and promote/demote, and koKR kicks (positional and reordered, but carrying no grammar escape). It does not repair ruRU, which is broken by the second mechanism below.
The client's grammar escapes are now handled, which brings ruRU kicks and ruRU / frFR / koKR rank changes back from total silence. Positional specifiers turned out to be one of two mechanisms that put characters into a format string which never reach the rendered message. The other is the engine's own text-substitution directives — ruRU
|3-N(…)(decline this word), frFR|2(choose the article), koKR|1a;b;(choose the postposition) — and none of the three builders stripped anything but hyperlink markup.The failure shape is identical to the deDE one and just as silent: in every affected string the escape lands inside the longest literal fragment, which is exactly what
BuildChatNeedletakes as its pre-filter, so the branch was skipped before the pattern was ever tried. On a Russian client an officer kicking someone produced noOnMemberLeft— and under build-once that leaves the ex-member in the roster until logout and propagates the stale set to every sister client, because the membership hash never moves.BuildChatPatternnow returns a list of patterns rather than one, because the Korean postposition glues one of several alternatives onto the preceding argument and cannot simply be deleted. The list also carries the unresolved reading of each escape-bearing string, tried first: whether the engine resolves these before the line reachesCHAT_MSG_SYSTEMhas not been observed on a client of those locales, so the library parses the message whichever way it arrives instead of betting on one.member.lastOnlineno longer freezes at login, and no longer contradictsisOnline.GetGuildRosterLastOnlineis only readable per roster row, and this release stopped reading rows after login — so the field had three ways to be wrong for the rest of a session, all of them introduced by the change above and all of them silent:- a member who came online kept their old tuple, so
GetMemberreportedisOnline = trueand "last seen three days ago", which the field's own documentation says is impossible; - a member who was online at login and then logged off kept
nil, so the one moment the library could state their offline time exactly was the moment it recorded nothing; - a member who logged in and back out kept the pre-login tuple, by then wrong by however long ago that was.
Presence transitions now write the field: coming online clears it, going offline stamps all zeroes. The residual is stated rather than papered over — someone offline for the whole session keeps their login-time value, which under-reports by at most the session length, because nothing can re-read that row without a rebuild. The header documents the field's resolution in those three cases so a consumer can display it honestly.
- a member who came online kept their old tuple, so
A comment that had become false is corrected, and it is worth calling out because it was load-bearing. The retail chat-lockdown gate said the cost of skipping a locked-down message was latency, since "the next post-lockdown
GUILD_ROSTER_UPDATEreconciles membership". There is no next rebuild, so a join, leave or kick announced inside a Mythic+/encounter/PvP window is now lost for the session rather than delayed. The trade is still right — touching a secret value is itself the harm, and the lockdown states are retail-only — but the residual is a gap, not a delay, and the comment now says so.The login retry now obeys the server's throttle instead of asking blind.
GUILD_ROSTER_UPDATEcarries exactly one payload —canRequestRosterUpdate, meaning "the throttle has lifted, you may ask again" — and this library discarded it. All three of Blizzard's own consumers gate their request on that flag and nothing else (Blizzard_Calendar.lua:3393-3396and:4172-4175,Blizzard_Communities/GuildRoster.lua:65-68, read from the Classic Era tree).The half that mattered was not the wasted request but the retry budget: the counter was incremented for requests the server had already said it would ignore, so
MAX_RETRIEScould run out having made zero effective attempts. On the old design the next rebuild covered for it; under build-once that is an empty roster nothing ever corrects. A throttled event now costs neither a request nor a retry. An absent payload still requests, so a caller that does not forward it degrades to the old behaviour rather than to silence.
Testing
336 specs pass, 0 fail; LibGuildRoster-1.0.lua is at 100% line coverage
(689/689); luacheck clean. New Tests/buildonce_spec.lua covers the ignored
event, the pending-detail pass and its TTL give-up, and the positional-format
fix.
New Tests/locale_spec.lua is the reason the second locale mechanism was found
at all rather than a third being left to a future bug report. It drives all
seven format strings in all eleven locales end to end through the real
handler — 85 assertions — and checks that the callback names the declared
argument, so a locale that puts the officer first cannot pass by accident. Its
seven escape-bearing rows are asserted in both readings, resolved and
surviving-literally, which is what lets the file be meaningful without a client
of any of those locales.
Roughly twenty existing specs drove membership and presence through
GUILD_ROSTER_UPDATE. They were rewritten against the real mechanism rather
than re-baselined to match the new code — several would otherwise have gone
green while exercising nothing at all, which is the failure mode this suite
exists to catch. Tests/presence_spec.lua's stale-row tests moved to the login
stream, the only window where a roster row can still contradict a chat signal.
Also in this release — the name cache is bounded, and every method that fills it says so
Closes peer-review findings 4 and 11
(docs/AUDIT.md). No API change and no behaviour change a consumer can observe,
beyond the memory ceiling itself.
Fixed
lib.nameCacheno longer grows without limit. Its only eviction was the realm-change wipe, so every distinct string ever normalized was retained for the session.lib.NAME_CACHE_MAX = 10000now caps it: on the insert that would exceed the ceiling the table is wiped and rebuilt from live traffic.The reason a cap is right rather than a note asking consumers to behave:
NormalizeNameis public, and a library cannot bound its own input.SetSisterRosterandMarkOnlinenormalize names that arrived over the addon channel from a peer, and neither validates them against a roster first —MarkOnlinein particular normalizes each name before the membership check that discards it, so a feed of names matching nothing left an entry per name.A crude wipe, not an LRU: this is a pure cache, so the only cost is recomputing the names still in use, and 10000 is far above any legitimate population (the largest guild measured against is 978 members, and a client also tracking several sister rosters is still a few thousand keys). Reaching the ceiling therefore means the input is unbounded, not that the guild is big.
One consumer was checked rather than assumed: TOGBankClassic normalizes strings parsed out of chat message bodies, which would have been genuinely unbounded input — but its
TOGBankClassic_Guild:NormalizeNameforwards to its own localNormalizePlayerName(Modules/Guild.lua:171-180) and never reaches this library. The cap is for the doors that cannot be checked.
Changed
- Documented the caching side effect on all nine methods that have it, not
just on
NormalizeName.IsInGuild,IsOnline,GetMember,IsInAnyRoster,IsInGuildScoped,IsOfficer,SetSisterRosterandMarkOnlineall normalize theirnameargument, and a consumer callingIsOnlinenever readsNormalizeName's doc comment. A lookup returningfalseornilstill caches the name it was asked about; that is now stated where it happens.IsInGuildhad no doc comment at all and now has one. @param name anyonNormalizeNamenow says what it costs. It was true of the type check and silently false of the lifetime consequence.
Also in this release — a stale roster row can no longer undo a chat-proven online
This is a behaviour change to the OnMemberOnline contract in the direction
consumers already assumed, so a consumer needs no code change — but it does need
the newer copy to get the fix, and LibStub hands out whichever copy loaded first.
Note that build-once narrows where this can happen at all: after stabilization
no roster row is ever read into presence again, so the stale-row window is now
confined to the login stream. The machinery is still required there, and still
required for the recentlyLeft half.
Fixed
OnMemberOnlineno longer fires twice for one login, andIsOnlineno longer contradicts a callback it just fired. A real-time signal — theCHAT_MSG_SYSTEM"has come online" message, or guild/officer chat traffic, which can only ever prove online — set the flag and fired the callback. AGUILD_ROSTER_UPDATEwhose row for that member was still stale then wroteisOnline = falsewith no callback, because the rebuild diff has no offline branch at all. The library therefore answeredIsOnline() == falsehaving just told every consumer the opposite, and the next rebuild carrying a fresh row sawwasOnline == falseagainstisOnline == trueand firedOnMemberOnlinea second time, minutes later. Nothing closed the window: the chat handlers deliberately do not callRequestGuildRoster, so it lasted until something else asked.This was not hypothetical.
TOGProfessionMaster'sOnCrafterCameOnlineraises a user-facing alert and carries no per-character dedup, so one login produced two alerts. Six addons in this fleet register the callback and none of them dedups — which is the strongest available evidence that at-most-once per real transition is what the contract ought to promise, and the reason the fix belongs in the library rather than in a documented "may fire twice".Fixed with
lib.recentlyOnline, the mirror of the existingrecentlyLeftmachinery: a 60-second stamp written whenever a real-time signal proves someone is there, and consulted by the rebuild so a stale row cannot overrule it. The rebuild resolves the effective online state per row before anything reads it, so the stored flag and the last-online read agree. A row saying online is never contradicted — it confirms the stamp rather than competing with it. Because the member stays online across the stale window, the second fire is closed by the same mechanism that closes the contradiction rather than by a separate rule.Three details that could each have gone the other way. The stamp is refreshed on every proof, not only on the transition, or the protection would expire mid-conversation for someone already recorded online. A chat-announced logoff clears the stamp unconditionally — it is a newer real-time signal and must win, or this machinery would resurrect a player we had just been told logged off. And the bias is deliberately toward online, because that is the cheap error here: a false online wastes a comm attempt, while a false offline makes
DeltaSyncskip a player who is really there and the data never propagates.The cost, stated rather than glossed: a member who logs off silently inside the TTL is reported online for up to 60 seconds. That is the same exposure
recentlyLefthas always accepted, and an announced logoff cuts it short immediately.Raised as finding 3 in
docs/AUDIT.mdon 2026-08-13 and left open on purpose, because it adds state to a contract other addons depend on. It was closed when a review session read all six consumer handlers and supplied the evidence that decision was waiting on.
Testing
Tests/presence_spec.lua, and all three decisions were mutation-tested rather than re-read — the stamp consult, the logoff clear and the refresh-on-every-proof were each broken in turn and each was caught, by four, two and one test respectively. Both sides of the TTL boundary are asserted, so an off-by-one in the comparison cannot pass both. The file was later rewritten to drive its stale rows through the login stream, once build-once removed the post-stabilization rebuild those tests had been using.
0.4.0 — the micro-stutter fix, lib:IsOfficer(), and two login-window bugs
The LibStub MINOR moves 11 → 12. The TOC fixes below are packaging rather than library behaviour and would not have moved it on their own; the performance work did.
Consumers should feature-detect IsOfficer (if GR.IsOfficer then) rather
than assume it. This library is embedded in several addons and LibStub hands out
whichever copy loaded first, so an older one without the method genuinely
circulates.
Performance
A player on TBC Anniversary reported a micro-stutter tied to guildmates logging
in and out. Every one of these is a measurement, not a guess: a full roster pass
on a 978-member guild cost ~27 ms, of which the raw GetGuildRosterInfo reads
were 1.31 ms. The rest was ours.
GetRosterHashis no longer computed on everyGUILD_ROSTER_UPDATE. It is the most expensive thing in the file by a wide margin — it collects every charKey, sorts them, concatenates them and runs FNV-1a over the result, which is an interpreted per-byte loop. A 500-member roster concatenates to roughly 10,000 characters, so a guildmate logging in — a presence change that cannot move a membership hash — cost 10,000 iterations of that loop plus an N log N sort on the main thread, every time. The old guard compared the hash after computing it, which was silent but not free.The rebuild now counts membership as it goes (previous size, new size, how many of the new were in the old), which is exact set equality in O(N) table lookups, and the hash is computed only when the set can actually differ.
OnRosterHashChangedfires exactly as before.NormalizeNameis memoized on the raw input string. It ran sixgsubcalls and a match per member — about 5,900 string allocations per pass on that 978-member roster, for the same 978 names every time. The cache is keyed on the connected-realm name it was built under and dropped when that changes, so the answers computed before the realm resolves at login are never served afterwards; failures are not cached. The normalization rules themselves moved tolib:ComputeNormalizedName, which is not public API — callNormalizeName.
Added
lib:IsOfficer([name])— one officer predicate for the fleet. Requested by ClassicCalendar, which had it written out ten times across five files under two incompatible rules: eight sites testedrankIndex <= 2, two tested the officer-note permission. In the very common0 = GM, 1 = Officer, 2 = Altlayout every Alt passed the rank tests, so an alt could wipe the guild's whole world-buff dataset while failing the permission check on the config screen beside it.The rule is the granted permission; the rank index is not consulted. A rank index is a position in a list the GM arranges however they like — nothing makes rank 2 an officer — while the permission is something a GM deliberately granted, to any rank they choose. Membership is checked first, and a spec asserts the permission API is not called at all when guildless.
A named argument returns the real answer for the player's own name and
nilfor anyone else — the client exposes no API for another member's permissions, andnilsays "unknown" wherefalsewould assert "not an officer" about someone who may well be one.nilis falsy, so aSetShown(lib:IsOfficer(n))call site still hides rather than erroring.Worth knowing if you are writing this yourself: the API is
C_GuildInfo.CanViewOfficerNote(). The bare global does not exist — it is in no flavour'sGlobalAPI.lua, has zero call sites in the Classic Era, Anniversary or retail client trees, and is not a deprecation fallback. Code calling it either errors or silently answers "nobody is an officer".New
docs/REQUESTS.md— the standing channel for what other addons ask this library for.docs/AUDIT.mdis for defects; a feature request is not a defect, and until now a consumer had nowhere to file one and resorted to the harness repo, where it went unread for six days.CLAUDE.mdpoints at both.Guild and officer chat now count as proof that a member is online.
CHAT_MSG_GUILDandCHAT_MSG_OFFICERare registered, and a message from someone the roster still records as offline flips them online and firesOnMemberOnline.This closes a real gap rather than adding a source of truth for its own sake: the "has come online" announcement is only seen if the client was listening, so a member who logged in before you did, during a
/reload, or inside a retail chat-messaging lockdown window stays recorded as offline while visibly talking — until the next full rebuild.It costs a few table lookups per message and nothing else. There is no pattern match, no roster read, and deliberately no
RequestGuildRoster— the roster round-trip is the expense this path exists to avoid, and withNormalizeNamememoized a guild's regular talkers each normalize once ever.One-directional by design: chat proves online, silence proves nothing. Going offline still comes from the system message and the rebuild diff.
Both events are flagged
SecretInChatMessagingLockdownin the retail client'sChatInfoDocumentation.lua, and theirplayerNamefield is not markedNeverSecret— so the sender name is itself a secret value under lockdown. The handler takes the same retail lockdown gateOnChatMsgSystemdoes, before anything reads the argument.
Changed
The "came online" transition has one implementation again. Adding guild chat as a presence signal left three sites doing the same guard-write-fire, so they are now one local,
MarkMemberOnline. The rebuild diff is deliberately not folded in — it compares a pre-wipe snapshot against fresh rows and gates onwasInitialized, so merging it would not be de-duplication. Raised and answered as finding 5 indocs/AUDIT.md.A self-audit of this performance work — round 2 in that file, the first round to read any Lua here — also left two findings open: a stale roster row can silently undo a chat-proven online and make
OnMemberOnlinefire twice for one login (MEDIUM, and pre-existing on the system-message path), and the new name cache has no bound. Neither is fixed here; both are written up with a suggested remedy.
Fixed
Sister rosters fed before the realm resolved kept unrealmed keys, and published a wire hash no other client could match. The home roster is wiped and rebuilt on every
GUILD_ROSTER_UPDATE, so a bare key normalized during the login window disappears within a beat. A sister roster is fed by a consumer and replaced only by the next feed, on that consumer's schedule — and the consumers feed at login by design: TOGProfessionMaster'sScanner:RefeedSisterRosters()exists to re-feed persisted rosters on login, and DeltaSync documents the same consumer-owns-persistence split.GetRosterHashruns over sister rosters, so the digest announced throughOnRosterHashChangedwas computed over bare keys and could not match a correctly-realmed client holding identical membership — and that digest is the signal telling a sister client whether to re-pull.Two spellings of one character — a bare
Boband a qualifiedBob-YourRealm, distinct keys only while the realm is unresolved — now merge rather than one silently overwriting the other, and the survivor is chosen from the data instead of from table order. The merged entry keeps the union of both entries' fields, so a feed supplyinglevelon one spelling andclasson the other loses neither, and the later of the two presence stamps wins.lib:RekeySisterRosters()now rewrites sister roster keys the moment the realm resolves, moving theMarkOnlinepresence stamps with them (leaving those behind would have dropped members fromGetOnlineMembersScopedsilently), and announces the corrected digest only for a roster that actually moved.Raised by peer review as finding 9 — against the fix and the justification written for finding 7 earlier the same day. The severity the reviewer left open resolved upward once the consumers were read.
NormalizeNameraised a Lua error during the login window, and had since it was written.GetNormalizedRealmName()returns nil between login and the realm resolving, and the realm was concatenated onto a bare name unguarded —attempt to concatenate a nil value, thrown out of the library into whichever consumer called it. Every caller that can take a bare name was exposed: the roster rebuild,IsInGuild's pre-build scan,SetSisterRoster, and the chat handlers.An unresolved realm now returns the bare name, which is what
GetNormalizedPlayera few lines below has always done for the same window. It is self-healing rather than merely brief: the name cache is dropped the moment the realm resolves, and the roster is wiped and rebuilt on everyGUILD_ROSTER_UPDATE, so no bare key survives.Found by writing the spec for something else — the memoization's realm-transition safety — which is the only reason it surfaced. Line coverage was 100% before and after; the line ran on every test, always with a realm present. Raised and answered as finding 7 in
docs/AUDIT.md.GuildRoster_BCC.tocis nowGuildRoster_TBC.toc, and it is the first manifest a TBC or Anniversary client has ever actually read here._BCCwith an underscore is neither spelling the client recognises: modern suffixes take an underscore (_TBC), and the only two legacy suffixes take a hyphen (-BCC,-WOTLKC) — and-BCCwas retired in Classic Anniversary Patch 2.5.5 regardless.An unrecognised suffix is not an error, it is simply not a special filename, so the client fell through to
GuildRoster.tocand its## Interface: 11509. Every TBC and Anniversary player has therefore been loading this library against a Vanilla interface number and seeing it flagged out of date, since the file was added. Nothing else covered that flavour — there was no_TBC.toc.The contents are unchanged:
20506was already correct, and_TBCserves TBC Classic and Classic Anniversary both, so one file replaces the one that could never load.GuildRoster_Wrath.tocdeclared## Interface: 30403; Wrath Classic is30405. Two patches stale, so the library showed as out of date on Wrath. The other five files were re-checked against the same table and are current.30403is a fleet-wide value that propagated by copying rather than a mistake local to this repo, so the sibling addons carrying it are unaffected by this fix.
Both were raised by a peer review — round 1 in docs/AUDIT.md,
a fleet-wide TOC sweep — and are answered in place there. That round read the
.toc files and no Lua at all; the library's own behaviour remains
unreviewed, which the file says plainly under Not covered.
Tooling
New
Tests/perf_spec.luaandTests/officer_spec.lua; the suite goes 164 → 195 tests, coverage 500/500 (100%). The performance work above had originally shipped with no spec asserting what it was for — coverage proved the new lines execute, nothing proved the expensive work was skipped, and an assertion onOnRosterHashChangedcannot tell the difference because the callback behaviour is deliberately unchanged.perf_specwrapsGetRosterHashand counts calls instead, which is the only way to assert an absence of work. Recorded as finding 8 indocs/AUDIT.md.Adopted WoWAPITesting
debd288→30ae97a, which delivered this addon's own contract.C_GuildInfo.CanViewOfficerNoteis now modelled in the harness (c44a0f3), so the local reference implementation staged inTests/env_guild.luaforlib:IsOfficeris deleted — the suite runs on the maintained stub with no spec changes. ClassicCalendar and TOGBankClassic had each derived the same predicate independently, which is why it belongs there rather than here.Two other adoptions in the range: the watcher script this repo was running gave every addon the harness's fleet-wide watch set, so it has been re-armed with the addon-scoped one that watches only this addon's own conversation; and
coverage.luatakes spec files as arguments, which drops a coverage run from minutes to 0.336s —CLAUDE.mdnow documents the invocation with the specs passed.Adopted WoWAPITesting
807fbd6→debd288— a pin move and nothing else. The single commit fixestools/dupscan.lua, which returned zero files on every invocation in every shell because its2>/dev/nullwas read by the cmd.exe thatio.popenspawns. Nothing in this repo ran it, and no finding here was based on it, so there is nothing to re-run. The suite is 171 passed, 0 failed, 100% line coverage (491/491) on the new pin.Adopted WoWAPITesting
f2b0114→807fbd6(57 commits). The suite runs 164 passed, 0 failed, 100% line coverage (455/455) on the new pin with no source or spec changes.Six of the 57 touch
env/and none reach this library: the tooltip minimum-width getter, the steerable width oracle, the container free-slot query,wow.onReset, the ScrollBox row surface, and acoverage.luaspeed-up. This lib draws nothing, opens no bags and holds no scroll list, and its specs do not reset from inside a helper — the shapewow.onResetexists for.The one behaviour change in the range was already adopted, which is worth saying rather than leaving implied.
strsplitstopped dropping a trailing empty field inc3129ba— an ancestor of the oldf2b0114pin — and only its announcement is new here. It could not have bitten regardless: every split in this library is a Lua pattern —CanonNametakes a name apart withstring.match("^(.-)%-(.-)$")and the chat handlers usestring.find/string.match.strsplitis called nowhere in the shipped file.Two adoption entries in the range ask for something rather than announcing a change. The hard tabs removed from the harness README are a pin move only. The other says an addon's own
Tests/HARNESS_CONTRACT.mdnever receives an answer — responses are written into the harness repo and nothing copies them back, so the file reads as an outbox of ignored requests at any pin. This repo's copy is not in that state: every harness response is already quoted under the request it answers.Adopted the peer-review protocol — new file
docs/AUDIT.md. It is the standing conversation for findings about this library: a review session writes them there (each with afile:lineand a concrete failure scenario) and only a session working on this addon can answer them. Append-only both ways — a fixed finding is answered in place and its Status row flipped, never deleted and never moved into a "Fixed" section.CLAUDE.mdnow points at it, which is the load-bearing half: the session watcher only sees writes made while a session is already running, whereasCLAUDE.mdloads every time. It is dev-only and cannot ship —docswas already in.pkgmetaignore:. No findings yet; the file exists so a review has somewhere to land and a later session somewhere to look, which is the failure the protocol was written after.Adopted WoWAPITesting
1cee3e9→f2b0114(87 commits). The suite runs 164 passed, 0 failed, 100% line coverage (455/455) on the new pin with no source or spec changes.Nothing across 28 adoption-log entries reaches this library, and that was checked rather than inferred from the green run. Every entry carrying a "consumers must" was grepped against this repo's own files: no
InviteUnit, no bareGetContainer*, noGetAddOnInfo/IsAddOnLoaded, noChatEdit_InsertLink, noIsModifiedClick, nolibs.registerneeding amajor, no hand-rolledGetBuildInfotable, no localC_DateAndTimestand-in.Tests/env_guild.luashadows none of them either — it steers the flavour and the clock through the harness's own surfaces.The one entry worth recording despite not biting:
time(dateTable)was broken for a day and silently returned "now" for every date conversion. ClassicCalendar found it only because 16 of their date assertions went red. This library does no date arithmetic — its only clock use isGetTime()for presence TTLs — so there was nothing here to collapse. An addon without date specs could not have told the difference.Adopted WoWAPITesting
47dd048→1cee3e9(four commits). The suite runs 164 passed, 0 failed, 100% line coverage (455/455) on the new pin with no source or spec changes.One of the four changes documented behaviour:
wow.reset()no longer rewindsGetTime(). The clock is monotonic in game, but libraries load once for the whole suite while specs reset between files, so a library caching a timestamp saw time run backwards. Two of the harness's own specs asserted an absoluteGetTime()after a reset and failed the moment it landed, and the harness's contract response warned this addon by name because these specs steer the clock.It does not bite here, and that was checked rather than read off a green run: no spec asserts an absolute
GetTime().Tests/chat_spec.luacompares the recorded stamp against the liveguild.state.clock, and the only other clock users — two inTests/sister_spec.lua, one inTests/roster_spec.lua— advance by a TTL relative to wherever the clock already sits. A full-suite run is the ordering that would expose a cross-spec clock leak, and that is the run that was made.The other three commits do not reach this addon: the
UnitResistancearity fix covers a global the lib never calls, theInCombatLockdown/ StaticPopup / print-to-chat additions are additive, and the remaining two are the harness flavour-divergence investigation and a fix to its owntools/.
Older versions (0.3.0 and earlier) are in CHANGELOG_ARCHIVE.md.
This mod has no additional files

