GuildRoster-v0.5.1
What's new
Changelog
All notable changes to LibGuildRoster-1.0 are documented here.
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/.
0.3.0 — CanonName, a canonicalizer that never supplies a realm
LibStub MINOR bumped to 11.
Additive: NormalizeName keeps its behaviour and every existing caller is
unaffected, with one deliberate exception noted under Changed.
Added
lib:CanonName(name)— canonicalizes a name's representation only and never appends a realm. A bare name comes back bare.The two functions do identical string cleaning and differ in exactly one thing, so choose by the provenance of the name:
- A name read from a local client API is bare only when the character is
on the viewer's own realm —
UnitName's second return isnilfor a same-realm unit, andGetGuildRosterInfoomits the realm for same-realm members. Appending the local realm there is a correct inference, soNormalizeNameremains right for roster rows, units and the local player. The library's own roster keys are unchanged. - A name that crossed the wire lost that context in transit. The
receiver's realm is not the sender's, so a bare
"Thrall"becomes"Thrall-Fairbanks"on one client and"Thrall-Whitemane"on another — one player, two strings, and no way back, because downstream they are two records rather than two versions of one. On a connected-realm cluster this is the normal path, not an edge case.CanonNameis for that.
Contract: idempotent, identical on every client regardless of the running realm or locale, and
nilfornil, a non-string, an empty or whitespace-only name, or a name whose portion before the hyphen is empty.It deliberately does not map
"unknown"to"Unknown"asNormalizeNamedoes. That check is a hardcoded English literal that never fires on a localized client, so honouring it would make the result depend on which locale is running — the defect rather than a guard. Callers that must refuse an unresolved name do it where the name is authored.Raised by Dibs as
DIBSREQ-LGR-001; both open contract questions (the stray:and theUnknownmapping) were answered by the consumer before this shipped.- A name read from a local client API is bare only when the character is
on the viewer's own realm —
Changed
NormalizeNamenow delegates toCanonNamerather than carrying its own copy of the string cleaning. Keeping two implementations of this logic is what led a consumer to reimplement it locally in the first place. Three behaviour changes follow from the delegation, all intended:- A non-string now returns
nilinstead of a coerced key.NormalizeName(123)used to return"123-YourRealm"— a well-formed-looking key that is pure garbage, which then sits in a consumer's store indefinitely instead of erroring where the bad value entered. Confirmed with the consumer that nothing relied on the coercion. - A stray
:is now stripped ("Thrall:-Fairbanks"→"Thrall-Fairbanks"). Defensive only: a colon is not legal in a character name, so removing one can only repair a malformed string. No consumer has named a source that produces it. "unknown"handling is unchanged inNormalizeNameand sits above the realm append, so the placeholder never acquires a realm suffix.
- A non-string now returns
Tests
- +18 specs (146 → 164), still 100% line coverage (455/455).
- The nine agreed contract lines are pinned table-driven, so a case cannot be quietly dropped, plus idempotency, cross-client determinism against two different local realms, and the negative assertion that no realm is appended even though one is resolvable.
- Mutation-verified: reintroducing the realm append in
CanonNamefails 7 specs, so the suite would catch a regression rather than passing vacuously. - Added the
duplicate-set-fielddiagnostic pragma to every spec file that reassigns a global (chat,login,query,roster,normalize). Reassigning a global per test is the harness's own steering convention, so the warning is noise there — but it is scoped per file rather than disabled in.luarc.json, because a duplicate field assignment inLibGuildRoster-1.0.luaitself could be a real defect and should still warn.
Verified in game
Not only offline. Two live clients, two flavours, two realms:
Classic Era (OldBlanchy) Retail (Mannoroth)
MINOR 11 11
IsReady, #members true, 986 true, 906
identity round-trip Galdof-OldBlanchy t t Brickhouse-Mannoroth t t
CanonName("Thrall") Thrall Thrall
NormalizeName("Thrall") Thrall-OldBlanchy Thrall-Mannoroth
The last two rows are the defect and the fix side by side: one input, two
clients, two different identities from NormalizeName and one from
CanonName. Retail additionally exercises the WOW_PROJECT_MAINLINE branch
and its different GetNumGuildMembers shape, and rebuilt a 906-member roster
with the delegated NormalizeName unchanged.
Documentation
README.mdanddocs/Curseforge_Description.htmlboth gain a worked example of the actual decision —NormalizeNameon a locally-read name,CanonNameon one from an addon message — plus a provenance lookup table, because choosing between them is the only real decision a consumer makes here and the callback examples alone did not show it.- Documented the failure mode explicitly: getting it backwards fails
silently.
NormalizeNameon a received name does not error; it yields a valid-looking key that differs from the one every other client computed, and the symptom surfaces much later as data that will not reconcile. - Documented the limit of the fix: this makes a name safe to compare and display, not safe as a database key. A character can be renamed or transferred, and a record addressed by a name moves when the name does.
CLAUDE.mdcorrected: FastGuildInvite carries no vendored copy of this library. It resolves this one through LibStub across eight runtime files, so a MINOR bump reaches it immediately — and it had hit this same receiver-dependent-realm defect independently, with a user-visible symptom (guild-policy elections disagreeing across a connected realm).
Test harness
- Adopted the shared
WoWAPITestingharness at47dd048(from4161af8, which no longer existed upstream).Tests/env_guild.luacollapsed from 353 lines to ~180: the guild model is nowenv.guild, Ace3 loading andfreshLibareenv.ace, and the addon-localTests/coverage.luais deleted in favour of the harness copy. No spec call sites changed. - Removed four globals this addon's test env was shadowing —
bit,securecallfunction,wipe, and a wholesale_G.C_ChatInfo = { … }. A local env that replaces a table the harness also owns silently discards the harness's version and fails layers away from where it was written.
0.2.5 — phantom level-up fix
LibStub MINOR bumped to 10.
One bug fix. No callback or method changed shape, and member.level is still
always a number, so consumers on MINOR 9 need no changes.
Fixed
A roster row with no level manufactured a phantom level-up. The rebuild defaulted a nil
levelto1. WhenGetGuildRosterInforeturned a partial row — name present, level not yet populated — the member was written down at level 1; the next rebuild wrote their real level andOnMemberLevelChangedfired(1 -> 60)with both presence flags true, which is indistinguishable from a real ding to a consumer. TOGTools' Gratz announced it in guild chat, congratulating someone for a level-up that never happened. Observed in game, not theorised — note that the API documentation typeslevelas a plain number and says nothing about partial rows, so this is an undocumented streaming case.The stabilization phase does not cover it: stabilization suppresses only the initial login stream, while the
"has joined the guild"branch callsRequestGuildRoster()and begins a fresh stream withinitializedalready true.The fix has two halves, because carrying the last known level forward closes only one of them:
- Member already in the roster.
level = wasLevel[norm]reuses the snapshot table the level diff already builds, so a partial row reproduces the level the member already had and produces no transition at all. - Member not in the roster yet — a fresh joiner, which is precisely the
"has joined the guild"path above. There is no previous level to carry, so the rebuild still writes1to keeplevela number, and records the name in a newlib.levelUnknownset. The level diff skips any member whose baseline was invented, so the1 -> 60on the following rebuild is suppressed. The flag propagates while the row stays partial, and clears once a real level lands, so a genuine ding after that reports normally.
lib.levelUnknownis per-guild transient state and is wiped alongsiderecentlyLeftwhen the guild changes.Five regression specs in
Tests/roster_spec.luacover both halves, the propagation across repeated partial rows, and the real-ding-afterwards case. All five fail against the previous code — the joiner spec fails with the literal phantom,{"Newbie-Testrealm", 1, 60, true, true}.- Member already in the roster.
0.2.4 — OnMemberLevelChanged
LibStub MINOR bumped to 9.
Purely additive: one new callback. No existing callback, method, or code path changed behaviour, so consumers on MINOR 8 are unaffected by upgrading.
Added
OnMemberLevelChanged(name, oldLevel, newLevel, wasOnline, isOnline)callback. Fires when a HOME member'sleveldiffers from the previous rebuild's value. Implemented as the exact twin ofOnMemberRankChanged: the pre-wipe snapshot loop inOnGuildRosterUpdategained a third table (wasLevel), and the fire sits in the sameif wasInitialized thendiff loop under the same two guards — post-stabilization only, and only for members present in the previous roster (wasLevel[name] ~= nil) — so a member arriving late in the login roster stream cannot be misread as a level-up.The callback deliberately reports the raw transition:
- It fires on a decrease as well as an increase. The lib does not know
which direction a given consumer cares about, and a consumer can filter for
newLevel > oldLevelin one line; a consumer cannot recover a transition the lib suppressed. - It is not gated on online state. Instead the member's
isOnlinefrom before the wipe and from after the rebuild are passed through as the 4th and 5th arguments. Those are the lib's to know — the pre-wipe snapshot is discarded by the time any callback runs — while the policy (announce only for members online at both ends, or ignore presence entirely) is the consumer's. Gating internally would force every consumer wanting a different rule to re-cacheisOnlineitself, which is exactly the bookkeeping this callback exists to delete.
Note that
levelis only written by theGUILD_ROSTER_UPDATErebuild —ParseSystemMessagenever touches it — so this callback fires on the next full roster rebuild after a level change, not at the instant it happens.- It fires on a decrease as well as an increase. The lib does not know
which direction a given consumer cares about, and a consumer can filter for
Changed
The library no longer depends on — or vandalises — the "Show Offline Members" setting. That checkbox (
SetGuildRosterShowOffline) filters whatGetGuildRosterInfoiteration returns, so a roster built while it is off contains only online members:IsInGuildsays false for every offline guildmate,GetAllMembersreturns a fraction of the guild, and each one looks like a fresh join when they next log in.The old approach forced the flag on once at
PLAYER_LOGIN(retail only). That was wrong twice over — it permanently overwrote a setting the player may have chosen deliberately, and asserting it once lost every race against an addon that flipped it later, silently degrading all subsequent rebuilds.Every scan now brackets itself instead: force the flag on, read, restore the player's value. The library is therefore independent of the setting rather than reliant on it, the player's choice survives untouched, and the restore also runs when a scan errors. The same bracket wraps the pre-initialization
IsInGuildfallback scan, which had the identical hole.This is safe to do synchronously because the filter is applied client-side to the already-cached roster with no server round-trip — Blizzard's own checkbox handler calls
SetGuildRosterShowOfflineand thenGuildStatus_Update()in the same frame, which would render stale data on every click otherwise.It also retires the open question about whether the Classic flavours filter the iteration: the flag is correct during our scan either way, so the answer no longer matters and the retail-only gate is gone. Consumers should stop calling
SetGuildRosterShowOffline(true)at init — it is now pure preference-clobbering with no benefit.
Fixed
- A departed member could fire a spurious
OnMemberOnline. The recently-left dedup seededwasOnline[name] = falsefor a player removed by the chat "has left" handler. The come-online branch testswasOnline[name] == false and member.isOnline, sofalseis precisely the value that fires it — the seeding did the opposite of what its own comment claimed. When a staleGUILD_ROSTER_UPDATEstill listed the departed player as online, consumers got a come-online event for someone who had just left the guild. Now seededtrue, which makes the test false and the seeding the genuine no-op it was meant to be.wasRankIndex/wasLevelare deliberately left unseeded so the rank and level branches skip the member outright. - Stabilization state survived a guild change, so
OnRosterReadycould fire on a partial roster. The guildless wipe clearedinitializedbut leftstableCountandpreviousTotaldescribing the previous guild's login stream. Joining a new guild whose first partial snapshot happened to carry the same member count foundtotal == previousTotalwithstableCountalready at the threshold, and declared the roster ready immediately — exactly the partial-snapshot misfire stabilization exists to prevent. The wipe now also resetsstableCount,previousTotal,retryCount, andrecentlyLeft, all of which are per-guild transient state. - The documented callback-registration idiom was wrong and would break every
consumer that copied it.
README.mdanddocs/Curseforge_Description.htmlboth showedlib.callbacks:RegisterCallback(...). CallbackHandler mixesRegisterCallback/UnregisterCallbackinto the library table and keepsFireon the registry, so that form is a nil call and raises at file scope — silently killing every callback registration in the consuming addon. Both docs now showlib.RegisterCallback(self, "Event", handler)and explain the split, andTests/smoke_spec.luapins the shape so the docs cannot drift again. Reported from TOGTools, which hit it directly. docs/was shipping inside the released zip. The.pkgmetaignore entry was written asdocs/, but the packager'sparse_ignoreonly trims a trailing/*, not a bare/— so the entry expanded to the never-matchingdocs//*anddocs/Curseforge_Description.htmlwas packaged into every release. Corrected to the baredocsform the packager requires. Three other entries were silently dead and have been removed:**/*.ps1and**/*.bat(match_patternuses shellcaseglobbing, which has no recursive**), and the dot-prefixed.git/.github/.vscode/.claude/.luarc.json/.markdownlint*entries (copy_directory_treeprunes everything matching.*unconditionally, so listing them implied coverage they weren't providing)."*.ps1"already covered the repo-root script that**/*.ps1was meant to catch, so nothing that was actually being excluded stopped being excluded. The file now carries the syntax rules as a comment so the same mistakes don't get reintroduced.Tests/was shipping inside the released zip. The new ignore entry was written as- Tests # specs + the WoWAPITesting harness submodule (dev-only), and the packager'syaml_listitemstrips only a leading-, whitespace, and one leading/trailing quote — never a trailing#comment. The comment text therefore became part of the pattern, which named no directory, soparse_ignorekept it as a file glob that matched nothing and all eleven dev-only files underTests/were packaged — ~95 KB of the 206 KB uncompressed. The comment now sits on its own line, and the syntax block above it gained the rule explicitly, because on a quoted entry the same mistake is far worse: the surviving closing quote breaks theevalofcopy_directory_tree, blanks its destination, and ships an empty zip that still exits 0. The firstv0.2.4zip carriedTests/; the tag was rebuilt against the corrected.pkgmetaand republished.wow-version-replication.ps1kept in step with the corrected.pkgmeta. The script derives its skip list from the sameignore:block, so the two syntax fixes above would have changed what it copies. It now mirrors the packager on both points: a wildcard-free entry naming a real directory is treated as a directory ignore (matchingparse_ignore's[ -d ]test), so the baredocsstill excludes the folder's contents; and everything dot-prefixed is skipped unconditionally (matchingcopy_directory_tree), so dropping the no-op dot entries from.pkgmetadoesn't start leaking dev metadata into the synced installs. Verified with-DryRun: the synced set is exactly the ten files that belong in the zip.- Removed an unsupported claim about Classic roster filtering. The old
retail gate in
OnPlayerLoginwas justified with "Classic/TBC/Wrath/Cata don't filter the iteration this way", which was never verified. Blizzard's Classic UI source (FriendsFrame.lua,GuildStatus_Update) shows that Classic'sGetNumGuildMembers()returns two values (total, online), that the guild panel picks its loop bound fromGetGuildRosterShowOffline(), and that it also guards each row withshowOffline or online— none of which establishes that the listGetGuildRosterInfo(i)indexes is left unfiltered. The self-bracketing scan described under Changed above supersedes the whole question: the flag is correct during every scan regardless of flavour, so the claim, the gate, and the unresolved investigation are all gone.
Development
Offline unit-test suite, at 100% line coverage. The library now ships specs that run with only a Lua 5.1 interpreter — no game client — built on the shared WoWAPITesting harness, added as a submodule at
Tests/wowapi(the same arrangement Dibs uses).Testsis excluded from the packaged zip via.pkgmeta, so nothing here reaches players.141 specs cover name normalization, the login/retry path, the wipe-and-rebuild and its diff callbacks, the show-offline independence above, the locale-derived chat parsing, the retail lockdown gate, the query surface, the cross-guild sister rosters, and the membership hash. Every library bug fixed in this release was found by writing the spec for the intended behaviour first and watching it fail — not by reading the code.
Run them with
lua Tests/wowapi/run.luafrom the addon root. The suite is deliberately local-only: there is no CI workflow, because a failing test is useful at the moment of the change, not in a report afterwards. Specs load the real Ace3CallbackHandler-1.0from the sibling AddOns install rather than a vendored copy, so they exercise the exact library that ships.Tests/coverage.lua— a zero-dependency exact line-coverage reporter. It takes the executable-line set from Lua 5.1 bytecode debug info (walking every nested prototype'slineinfo) rather than guessing from source text, and excludes lines whose only instructions are jumps, which a line hook can never report.lua Tests/coverage.lua LibGuildRoster-1.0.luaexits non-zero if any executable line is unexercised. Note this is line coverage, not branch coverage: a compound condition counts as covered once it executes, even if one side of it never varied.Tests/HARNESS_CONTRACT.md— a specification of what would be worth moving into the shared harness eventually (securecallfunction, the guild API surface, and a signed-32-bitbit). None of it is blocking: the suite runs at 100% today, withTests/env_guild.luaproviding that environment locally.The hash specs validate
fnv1a32against an independently-structured reference implementation, which is itself pinned to the published FNV-1a vectors — so the digest is certified correct, not merely self-consistent — plus a golden value guarding the frozen wire contract.
0.2.3 — 12.0.1 API audit: compat block, isMobile retired, MoP Classic TOC
LibStub MINOR bumped to 8.
Full re-evaluation of the lib's WoW API surface against Blizzard's 12.0.1 source across every shipped flavour (Classic Era, Anniversary, MoP Classic, retail Mainline). Outcome: the lib was already sound — the retail secret-value gate is correctly scoped and no dependency was removed — but the audit surfaced one stale field and prompted a maintainability refactor.
Fixed
isMobileno longer trusts a retired roster slot.GetGuildRosterInforeturn position 14 was the WoW Companion "mobile" flag. The companion remote was retired in 12.0.1 and Blizzard's own source now names slots 14/15_deprecated1/_deprecated2, so the slot is dead on modern clients. TheisMobilemember field is retained for API compatibility but now documents that it readsfalseon current clients; consumers must not rely on it. The read itself is unchanged (nil-safe →false), so any flavour that still populates the slot passes through without regression.
Changed
Flavor-compat refactor (no behaviour change). The version-divergent branches — the
C_GuildInfo.GuildRoster()request wrapper, the retailIS_RETAIL(WOW_PROJECT_MAINLINE) discriminator, and theGetGuildRosterInfopositional decode — are now consolidated into a single marked "flavor compat" section at the top of the file (a newReadRosterRowhelper owns the positional decode). Kept in-file rather than a separatecompat.luaso the lib stays a single self-contained embeddable module. A future client patch that moves an API is now a one-section edit.Chat-lockdown comment corrected for 12.0.1.
C_ChatInfo.InChatMessagingLockdownnow exists on the Classic clients too, but only retail flagsCHAT_MSG_SYSTEMas a secret value under lockdown, so the retail (WOW_PROJECT_MAINLINE) guard — not the presence of the API — remains the correct discriminator. The gate itself is unchanged and verified correct.TOC
## Interfaceversions bumped to current builds. Classic Era11508 → 11509, Cataclysm40400 → 40402(Cata Classic's final 4.4.2), Mainline110207, 120001, 120000 → 120007(Midnight 12.0.7). BCC (20506, current BCC Anniversary 2.5.6) and Wrath (30403, final Wrath Classic 3.4.3) are already at their latest builds and unchanged.
Added
GuildRoster_Mists.toc(Interface 50504) for Mists of Pandaria Classic. The classic-progression realm is now MoP Classic (WOW_PROJECT_MISTS_CLASSIC), which the previous TOC set (topping out at Cata 40400) did not target. The lib now ships six per-flavour TOCs.
0.2.2 — In-game title matches the CurseForge name; BCC Interface 20506
No LibStub MINOR bump — this release changes TOC metadata only; the shipped
LibGuildRoster-1.0.lua (MINOR 7) is byte-for-byte unchanged, so the runtime
behaviour and the LibStub-registered library are identical to 0.2.1.
Changed
GuildRoster_BCC.tocbumped to## Interface: 20506(was20505) for Burning Crusade Classic Anniversary patch 2.5.6 (build 2.5.6.68502). Keeps the TOC off the "out of date" list on BCC.## Title:in all five TOCs is nowLib: LibGuildRoster(wasLib: GuildRoster). The CurseForge project is named LibGuildRoster, but the in-game AddOns list and BugSack previously showedLib: GuildRoster, and a consumer hitting FastGuildInvite'sFailed to load missing dependency [GuildRoster]couldn't find the lib by searching "GuildRoster" on CurseForge (it only surfaces under "LibGuildRoster"). Aligning the display title with the CurseForge name lets a user read either surface and land on the right download.No effect on dependency resolution or install. The addon folder is still
GuildRoster(set bypackage-as: GuildRoster), and WoW resolves## Dependencies: GuildRosteragainst that folder name, not the title — so FastGuildInvite's dependency link is unchanged.## Title:is a display string only; the TOC filenames still match the folder as WoW requires.
0.2.1 — Retail taint-safe CHAT_MSG_SYSTEM handling, and cheaper scanning
LibStub MINOR bumped to 7.
Fixed
- Retail "secret value" taint from CHAT_MSG_SYSTEM parsing. On retail
(TWW/11.x+)
CHAT_MSG_SYSTEMpayloads are marked as protected "secret" values only during "chat messaging lockdown" — an active Mythic+/Challenge Mode, an in-progress instance encounter, or an active PvP match (the threeChatMessagingLockdownReasonstates). While locked down, any string operation on the payload (compare,match,gsub, concat) taints execution, and that taint leaks into shared UI, surfacing as unrelated Blizzard errors (MoneyFrame_Updatearithmetic,AreaPoiUtilSetPadding, tooltip comparisons) "while execution tainted by '<addon>'". Outside lockdown the payload is an ordinary string. - The previous
pcall(function() return message == "" end)probe was itself the taint source — the==comparison is a forbidden operation on a secret value, so the probe performed the very taint it was meant to detect. OnChatMsgSystemnow gates onC_ChatInfo.InChatMessagingLockdown()and skips parsing while it returns true. Because the value is only secret during lockdown, skipping that window means the lib never operates on a secret value and never produces taint — prevention at the boundary, not operate-then-catch. The CHAT_MSG_SYSTEM body was extracted intolib:ParseSystemMessage;OnChatMsgSystemis now a thin lockdown gate in front of it.- No regression to normal play. Outside lockdown (questing, town, open
world — i.e. virtually all play, and when guildmates actually join) nothing
is secret, so online/offline/join/leave parsing and callbacks fire exactly as
before. The gate is guarded by
WOW_PROJECT_ID == WOW_PROJECT_MAINLINE(the same retail guard theSetGuildRosterShowOfflinepath uses) plus aC_ChatInfo.InChatMessagingLockdownfeature-detect, so Classic / TBC / Wrath / Cata — which have no secret-value system — never take the branch and parse unchanged. - Known limitation (tracked separately): transitions that arrive during
an encounter / M+ / PvP match are skipped in real time and reconciled by the
next post-lockdown
GUILD_ROSTER_UPDATEfor membership. Real-time join/leave callbacks for those in-lockdown events still need the roster-diff path (keyed off the accessible roster) to be fully restored.
Performance
- CHAT_MSG_SYSTEM scanned with a cheap pre-filter.
CHAT_MSG_SYSTEMis a high-volume firehose (loot, achievements, M+ notices, system spam, ...), and the handler previously stripped chat-link markup (twogsubs) and ran up to five anchored pattern matches on every line before deciding it wasn't a guild transition. Each event type now has a precomputed plain-text needle (derived per-locale from the sameERR_*global viaBuildChatNeedle, e.g." has joined the guild."); a plainstring.findfor it on the raw message rejects the overwhelming majority of traffic before any markup strip or capture runs. Markup is stripped lazily and only once, only on a needle hit. Because the needle is a substring of every message its pattern accepts, the pre-filter can never drop a line the pattern would have matched, and a non-derivable needle degrades to always attempting the match. This matters because the library sits in the chat hot path of every consumer addon.
0.2.0 — Cross-guild (sister-roster) support
LibStub MINOR bumped to 6.
This release adds a multi-roster store on top of the single self-scanned
guild roster, so a consumer can track one or more sister guilds whose
membership and presence are fed in from outside (e.g. discovered over
/who and synced across whispers by a higher layer). The library stays
framework-agnostic — it only stores, normalizes, hashes, diffs, queries,
and ages presence. Everything from MINOR 5 is unchanged and home-only;
all new behaviour is additive.
Added
- Multi-roster store. The self-scanned home roster still lives under
self.rosterand every existing method reads it untouched. Externally fed sister rosters live underself.rosters[guildKey], whereguildKeyis"Faction-GuildName". lib:GetHomeGuildKey()→"Faction-GuildName"(the locale-independentUnitFactionGrouptoken + the rawGetGuildInfoname, e.g."Horde-The Brave Ones"), ornilwhen guildless / before guild data resolves at login. Cached like the realm name and cleared on the not-in-guild wipe so a guild switch re-resolves.lib:SetSisterRoster(guildKey, members[, meta])— wipe-and-replace ingest for a sister roster (preserves the "stale ex-members are impossible" guarantee).membersentries are either a bare"Name-Realm"string or a{ name, class, level, rank }table; every name is run throughNormalizeName.metais opaque caller metadata, stored and read back viaGetRosterMetabut never interpreted (e.g. the provider charKey and snapshot timestamp for provenance); a re-feed that omitsmetakeeps the previous value, and onlyRemoveSisterRosterclears it. Ignored whenguildKeyis the home key — the authoritative self-scan always wins.lib:RemoveSisterRoster(guildKey)— silent teardown of a sister roster, its presence overlay, meta, and cached hash.- Presence overlay, separate from membership.
lib:MarkOnline(guildKey, names)stamps a last-seen timestamp for sister members;lib:GetOnlineMembersScoped(guildKey)returns, for a sister, members stamped withinlib.PRESENCE_TTL(default 120 s), and for the home guild the authoritative livemember.isOnline. Presence is stored in a separate table (self.presence[guildKey]), not inside the member tables, so aSetSisterRosterresync never wipes liveness. The lib only ever records a last-seen time, never a hard "offline" — the upstream presence source is sampled, so presence ages out and self-corrects. Presence is transient and never persisted. - Scoped queries (additive; the five home-only getters are unchanged):
lib:IsInAnyRoster(name)→guildKey | nil(home takes precedence),lib:IsInGuildScoped(guildKey, name)→ boolean,lib:GetRoster(guildKey)→{ charKey = member },lib:GetRosterMeta(guildKey)→ the opaquemetalast fed toSetSisterRoster,lib:GetKnownRosters()→ array of guildKeys. lib:GetRosterHash(guildKey)→ a stable 8-hex-digit FNV-1a digest over the sorted membership set only. It excludesisOnline, zone, status, rank, and presence, so it does not churn when someone logs in or out; two clients with the same membership produce the identical hash.OnRosterHashChanged(guildKey, newHash)callback. Fires when a roster's membership set changes — for the home roster on a post- stabilization rebuild, and for a sister roster on eachSetSisterRosterthat alters the set. This is how a sister client learns to re-pull.
Changed
OnMemberJoined/OnMemberLeftnow pass aguildKey2nd argument. Home joins/leaves pass the home key; sister diffs pass the sister key. Existing one-arg consumers harmlessly ignore the extra argument. The firstSetSisterRosterfeed for a guild is treated as a baseline — it fires the hash but not a join per member, so a consumer re-feeding its persisted roster on every login doesn't re-welcome the whole sister guild (mirroring the home path's login-flood avoidance).NormalizeNamenow squashes internal whitespace in the realm portion ("Name-Argent Dawn"→"Name-ArgentDawn"). This is a no-op for the home path and already-normalized fed names — pure defense-in- depth so a mismatched realm spelling in aSetSisterRosterfeed can't silently fail every cross-roster lookup.
Notes
- Embedding coordination. Because this lib is embedded in multiple
addons, the MINOR bump and every embedder's
.tocdependency must move together, orLibStubmay hand an older copy that lacks the new methods. Consumers should feature-detect each new method (if lib.SetSisterRoster then ...) so a load race against an older copy degrades to a no-op rather than erroring. - Hash is a frozen contract. The FNV-1a algorithm is fixed: changing it after ship would make mixed-version peers disagree forever. It is only ever compared between instances of this library.
- No persistence in the lib — it stays in-memory. The consuming addon
persists sister rosters in its own SavedVariables and re-feeds them via
SetSisterRosteron login/reload.
0.1.0 — GetNormalizedPlayer, documented normalization helpers
LibStub MINOR bumped to 5.
Added
lib:GetNormalizedPlayer()getter. Returns the local player's canonical"Name-Realm"string, formatted identically to the roster keys so consumers can compare it directly againstGetMember/GetAllMembersoutput. Reuses the lib's cached realm name (GetRealmName). Falls back to the bare"Name"during the brief pre-world-enter window where the realm name isn't resolved yet, and returnsnilifUnitNameisn't available. Deliberately does not route throughNormalizeName, which would emit a trailing-hyphen"Name-"in that fallback window.
Documentation
NormalizeNameandGetRealmNameare now documented as public API. Both have always been public methods; they're now listed in the file header, the README, and the CurseForge description so consumers that build"Name-Realm"strings (or compare against roster keys) can rely on them. No behaviour change to either method.- CurseForge description brought back in sync. Its Public API list
was missing
IsReady(added in 0.0.2) and itsGetMemberfield list still showed the pre-0.0.2 short form; both are corrected, alongside the newGetNormalizedPlayer/NormalizeName/GetRealmNameentries.
Notes
- The package version jumps from 0.0.x to 0.1.0 now that the standalone packaging and external-reference flow are validated against a real consumer. The LibStub MINOR continues to increment by one per behavioural change (now 5), independent of the package version.
0.0.4 — Defuse retail "secret string" taint in CHAT_MSG_SYSTEM
LibStub MINOR bumped to 4.
Fixed
CHAT_MSG_SYSTEMno longer errors on protected "secret" string values. Retail (TWW+) sometimes delivers internal BNet / whisper- related signals viaCHAT_MSG_SYSTEMwith a server-side protection flag. Any operation on such a value (==,gsub,match) throwsattempt to compare local 'message' (a secret string value, while execution tainted by '<addon>')from the first comparison inOnChatMsgSystem. The handler then aborted, and on some clients the event frame stayed tainted so subsequent legitimate guild events (online / offline / join / leave) were missed entirely. The lib now pcalls the empty-string check at the top of the handler; if the comparison itself errors, the message is protected and we silently bail before touching it further — those messages would never match our guild-event patterns anyway. Reported as 18× repeated errors from a consumer addon (FastGuildInvite) embedding this lib on retail.
0.0.3 — Switch to Ace3-supplied LibStub / CallbackHandler-1.0
Fixed
- Standalone install no longer errors on missing LibStub. v0.0.1 and
v0.0.2 declared
LibStubandCallbackHandler-1.0as.pkgmetaexternals, so a raw git checkout (or a dev sync that didn't go through the CurseForge packager) had noLibs/LibStub/LibStub.luaon disk and the TOC'sLibs\LibStub\LibStub.lualine produced aLUA_WARNING: Error loading GuildRoster/Libs/LibStub/LibStub.lua. The TOCs no longer reference those files at all; Ace3 supplies both libraries globally viaLibStubbeforeLibGuildRoster-1.0.luaruns.
Changed
- Ace3 is now a required dependency. All five TOCs declare
## Dependencies: Ace3and.pkgmetalistsrequired-dependencies: - ace3, so CurseForge auto-installs Ace3 alongside this lib and the game loader pulls it in first at runtime. Theexternals:block has been removed from.pkgmeta. enable-toc-creation: nobecause we ship explicit TOCs for every flavour and don't need the packager to synthesise any.
Notes for embedding consumers
If your addon embeds this lib via externals:, you still need
LibStub and CallbackHandler-1.0 available before
LibGuildRoster-1.0.lua loads — either from your own embedded copies,
from Ace3 as a dependency of your addon, or from any other lib that
brings them in. The lib itself no longer ships them.
No library API or behaviour changes; LibStub MINOR stays at 3.
0.0.2 — Locale fix, expanded member data, rank-change callback
LibStub MINOR bumped to 3.
Fixed
- CHAT_MSG_SYSTEM patterns are now locale-aware. The hardcoded English
strings (
"has come online","has joined the guild", etc.) silently failed on every non-English client — German, French, Russian, Chinese, and others were all reduced to picking up online/offline/join/leave transitions only on the next fullGUILD_ROSTER_UPDATErebuild instead of in real time. Patterns are now built once at file load from Blizzard's localized format-string globals (ERR_FRIEND_ONLINE_SS,ERR_FRIEND_OFFLINE_S,ERR_GUILD_JOIN_S,ERR_GUILD_LEAVE_S,ERR_GUILD_REMOVE_SS). Chat-hyperlink markup is stripped from incoming messages before matching, and brackets[Name]are made optional in the pattern so both bracketed and bare forms match.
Added
- Expanded
GetMemberfields. The member table now includeszone,publicNote,officerNote,status(0 = available, 1 = AFK, 2 = DND),isMobile(Companion App connection), andlastOnline({ years, months, days, hours }for offline members, nil otherwise). Previously these were silently discarded from theGetGuildRosterInforeturn — consumers had to re-iterate the whole roster themselves to recover them. lib:IsReady()getter. Returns true once the first stabilized full roster build has completed. Consumers that register callbacks after the lib has already initialized (common on/reload, or when the consumer addon loads later than the lib) would otherwise miss theOnRosterReadyfire; checkIsReady()and run the ready-time logic inline when it returns true.OnMemberRankChanged(name, oldRankIndex, newRankIndex)callback. Fires when a member's rank changes between rebuilds. Skipped during the initial roster stream so partial-snapshot rank transitions can't misfire; only fires for members that existed in the previous roster.
0.0.1 — Initial release
First release of LibGuildRoster-1.0 as a standalone CurseForge library, extracted from its prior home as a vendored copy inside FastGuildInvite. Pre-1.0 versioning while the standalone packaging and external-reference flow are validated end-to-end against a real consumer build.
Library behaviour
- Tracks the WoW guild roster with wipe-and-rebuild semantics — no stale ex-members possible.
GUILD_ROSTER_UPDATEdrives full rebuilds;CHAT_MSG_SYSTEMdrives real-time online / offline / join / leave transitions between rebuilds.- Retries the initial roster fetch up to 5 times at login to handle the
window where
GetNumGuildMembers()returns 0. - Stabilization gate (2 consecutive rebuilds with the same member total)
before
OnRosterReadyfires, preventing partial-snapshot misfires during the retail login roster stream. OnMemberJoinedfires only from the authoritativeCHAT_MSG_SYSTEM"X has joined the guild" message, never from roster diffs.- Recently-left dedup (60 s TTL) prevents stale
GUILD_ROSTER_UPDATEresponses from misfiringOnMemberJoinedfor a player who just left. - On retail, calls
SetGuildRosterShowOffline(true)atPLAYER_LOGINso the rebuilt roster sees the full guild rather than only online members.
Compatibility
- Classic Era (Interface 11508)
- Burning Crusade Classic (Interface 20505)
- Wrath Classic (Interface 30403)
- Cataclysm Classic (Interface 40400)
- Mainline / Retail (Interface 110207, 120001, 120000)
Public API
IsInGuild, IsOnline, GetMember, GetAllMembers, GetOnlineMembers.
Callbacks
OnRosterReady, OnRosterUpdated, OnMemberOnline, OnMemberOffline,
OnMemberJoined, OnMemberLeft.
This mod has no additional files

