UltimateGuildRoster-v0.2.0
What's new
Changelog
All notable changes to Ultimate Guild Roster. Work in progress sits under [Unreleased]; the
version number is decided at release prep.
[0.2.0] - 2026-10-14
The MVP features: roster tracking, the event log and Log tab, alts and mains, the ban list, member details, the GRM import, the Roster, Alts, Bans and Sync tabs, and the export window; plus the macro tool's and stats' logic with no UI yet. Almost none of it has been seen in game; the GRM import window was (its text-overlap fix below came from the operator's in-game report).
Needs LibAceGUIWidgets v0.5.0 (MINOR 43), released 2026-10-14 (tag LibAceGUIWidgets-v0.5.0). On
an older LibAceGUIWidgets, one Escape in combat on any UGR question also closes the main window; the
settings page shows check boxes instead of toggle tokens; and a language switch rebuilds the ban and
note dialogs instead of relabelling them.
Added: clearing the log, and a warning when it grows big (LOG-07, LOG-08; EL-15, EL-16, EL-20, EL-22 to EL-25)
- New
EventLog:Clearable(mode, arg)andEventLog:Clear(mode, arg):"filtered"(the rows the Log tab shows),"range"({ from, to }server epochs, from inclusive, to exclusive) or"all". Clear needs the general edit threshold (ns.Sync:WhyNotAuthor("general"), refused before anything is touched), keeps the order of what stays, logs nothing about itself, and sendsUGR_LOG_CLEARED(count)when any went. An unknown mode, or a range without two numbers, raises a named error (Peer Review F4, thread 38bd2214). Changed from the design: event-log.md's range was log indices; a player picks days, not positions. - New
EventLog:CheckSize()andEventLog.SIZE_WARNINGS = { 50000, 100000 }, run after everyAdd,AddChangesandAddImportedthat added something: one chat line, in the chat language, to the chat windowsettings.chatFramechose (Peer Review F3), the first time the guild's log reaches each size. Remembered in the guild's newlogWarnedtable (an AceDB default inSavedVariables.lua); a clear forgets every size the log is now below. - The Log tab's three Clear buttons are built (they were greyed "Not built yet."). Clear shown entries
and Clear the whole log ask first with the count; Clear a range opens a form dialog
(
ns.Constants.LOG_RANGE_DIALOG_NAME, "UGRLogClearRange") filled with the oldest and newest entry's days, which refuses an unreadable date, From after To or an empty range under the dates, then asks. Days are read on the player's own clock throughW.DatePicker, the clock the When column draws. Each button is greyed with the rank reason, or "Nothing to clear.", re-read on every repaint. A refusal at OK is shown over the tab or under the dates. The tab repaints onUGR_LOG_CLEARED. - Found while building it: a named form dialog sits on the client's Escape list beside the main window,
so one Escape closed both. The range dialog now joins the window's Escape layer (
Log:Layer), as the member panel's editors do; the spec failed on exactly that before the fix (run 20261014-140110.758). ns.UI.Strip's own strip, for a LibAceGUIWidgets withoutCreateButtonStrip, now takes a functionreasonand hasstrip:Refresh(), as the library's does; the Log tab needs both on either library.- The export window drops its built log rows and redraws an open log set on
UGR_LOG_CLEARED(Peer Review F1): an open log export kept showing cleared entries. docs/design/event-log.md: the API table, a "Clearing (LOG-07)" section, the clear gate written as a courtesy on local data and not a protection (Peer Review F2: every player sets their own thresholds and the log is never synced), the local-days choice (F6), and EL-22 to EL-25.- Tests:
Tests/event_log_spec.luaEL-15 and EL-16 (red first, run 20261014-135104.824), the chat window and named-range specs (red, run 20261014-140247.028);Tests/log_tab_spec.luaEL-20 rewritten for the built buttons, because the old EL-20 asserted every Clear button greyed "Not built yet.", and EL-22 to EL-25 new (red, run 20261014-135809.932);Tests/ui_strip_spec.luafunction reasons on both strips (red, run 20261014-135930.715);Tests/export_window_spec.luathe clear (red, run 20261014-140214.425). Two spec files declare theirafter_eachbefore the examples: the harness's runner ran each example as it was declared, so a hook written after them applied to none (WoWAPITesting confirmed and fixed it in f5389cb, thread c3157954; UGR's pin has not moved yet). - Every clear removes exactly the entries counted in its question (Peer Review A2, thread ace8973e):
"Clear the whole log" and a range used to re-pick at OK, so an entry logged while the question was up
went too. All three now confirm a snapshot through
EventLog:Clear("filtered", rows). - A guild change closes an open clear question and the range dialog, and a question that survives it refuses at OK with "The guild changed. Nothing was cleared." (A3): it used to clear the new guild's log.
ns.UI.Prompt/ns.UI.ClosePrompt: every tab's question goes through one wrapper that keeps the prompt's token, so the window closing or a guild change can close it. The Log, Bans and Sync tabs use it.- In combat, one Escape on any of our questions also closed the main window. The fix is in
LibAceGUIWidgets MINOR 43, released as v0.5.0 (
ShowDialogjoins the window's Escape layer for one show, inbox 3d3634d0); on an older library it still happens. A UGR workaround was built and withdrawn, because it editedUISpecialFramesfrom inside the client's Escape walk (Peer Review).Tests/log_tab_spec.luaandTests/bans_tab_spec.luacarry the in-combat acceptance specs; they failed before the library's fix existed. v0.5.0 also covers a question over the Clear a range form: the first Escape closes the question, the second the form, the third the window (LibAceGUIWidgets, thread 3d3634d0; not run against UGR's suite). - Spec removed, with its reason:
Tests/log_tab_spec.lua's "leaves the window's layer when it closes, so the shared prompt is back on the Escape list". LibAceGUIWidgets MINOR 43 puts the prompt's name back on the Escape list at the next show outside a layer, not when the prompt hides (LibAceGUIWidgets-1.0.lua,finishDialogandShowDialog), so the spec asserted something the library no longer does. The "another addon's prompt" spec still checks the name is back while that prompt is up.
Changed: asking for the Bans tab below the ban rank opens the Roster tab (UI-01 to UI-03)
MainWindow:Open("bans")below the ban rank opened the last tab used; ui.md UI-02 says the first tab. It now opens the Roster tab, whether the window was closed or already open. Specs inTests/bans_tab_spec.lua(red first, run 20261014-134635.153).- UI-05 (Escape with the member panel's date editor open closes only the editor) had no spec; added to
Tests/member_panel_spec.lua, shown able to fail by leaving the editor out of the Escape layer (run 20261014-134729.751), then reverted. Tests/roster_tracking_spec.luaRT-20's comment named settings spec ST-05; the milestone spec is ST-07.
Whole suite 1270 passed, 0 failed, 4 pending (run 20261014-140631.180); GUI/Tabs/Log.lua,
GUI/UI.lua, GUI/ExportWindow.lua, GUI/MainWindow.lua, Modules/EventLog.lua, Constants.lua,
SavedVariables.lua at 100% (coverage run 20261014-140959.350). Nothing seen in game.
Added: Hardcore deaths (ROSTER-12)
- New
ns.Compat.IsHardcore():C_GameRules.IsHardcoreActive()where the client has it (documented in the Era, TBC, MoP and Forever trees; the Live tree'sGameRulesDocumentation.luahas no such function), false otherwise.C_GameRulesadded to.luarc.json's globals. - New
Roster:OnDeath(line)onHARDCORE_DEATHS, registered inRoster:Enableon every client and gated onIsHardcore()at each line. The event's one field is calledmemberName, but the game's raid-warning frame shows it unchanged as the whole warning line (wow-ui-source-classic_eraBlizzard_FrameXMLUtil/Classic/RaidWarning.lua:117,:123-124), and Deathlog's parser builds that line from the localizedHARDCORE_CAUSEOFDEATH_*strings. So UGR does not take it as a name: it looks for each current, living member's short name in the line as a whole word (UTF-8 bytes count as letters, so "Zoë" is not cut short). Exactly one match is markeddead,diedAt(realm clock) anddiedLeveland logged asdiedthroughRoster:Apply; none (a stranger, which the player's "All" death-alert setting delivers) or two (a duel line) record nothing. Known cost: a death whose line names a second member is not recorded. - New setting
tracking.deathTag(off by default) and its switch in the settings page's Tracking section, hidden off a Hardcore realm. With it on,Roster:TagDeathappendsL["death_tag"]("[Dead]") to the public note throughMembers:SetGameNote, after a space unless the note is empty, only when the result fits in 31 characters and the note does not already hold it. The note's text is never replaced or cut; without the right to edit the note nothing is written. docs/FEATURES.mdanddocs/design/roster-tracking.mdsaid the payload was the member's name; corrected, with RT-33 to RT-37 added.docs/design/settings.mdlists the new default.- Tests: new
Tests/roster_hardcore_spec.lua(14 specs) red first (run 20261014-131118.646, every example erroring on the missingRoster.OnDeath). The death lines in it are illustrative: their real English wording is in no local tree. The harness has noC_GameRules, so the file installs its own and removes it after each example.Tests/settings_spec.luaST-01 now listsdeathTag = false, andTests/settings_panel_spec.luaST-10 placesdeathTagin Tracking, both because the setting is new. Whole suite 1216 passed, 0 failed, 4 pending (run 20261014-131652.467);Modules/Roster.lua,APICompat.lua,Modules/Settings.lua,GUI/SettingsPanel.lua,SavedVariables.lua,Locale/enUS.luaat 100% (coverage run 20261014-131935.014). Not seen in game: whether the event fires with the death alert set to Never is not verified. - Peer Review A1, thread d27fdb70: the death tag is written only while the player's
hardcoreDeathAlertTypeCVar is 1, Guild (newns.Compat.DeathAlertsGuildOnly(), bareGetCVaras Era's own Lua calls it;GetCVaradded to.luarc.json). At All a line may be a stranger's who shares a member's short name (a connected realm) or is named like a word in it, and the note is guild-visible. The death itself is still recorded at every setting. The switch's tooltip says where the setting is (Options, Interface;Blizzard_SettingsDefinitions_Frame/Interface.lua:128callsInterfaceOverrides.AdjustDisplaySettings). That the server filters lines to the guild at 1 is not verified. Specs red first (run 20261014-132507.316, then RT-37's tooltip red at run 20261014-132617.229); the file is 16/16 (run 20261014-132654.737). A2 (the short-name stranger) and A3 (a wrong death is permanent, no undo) are written into roster-tracking.md as known costs. - Harness pin b37b60e -> ad6dc6b (WoWAPITesting delivered
C_GameRules.IsHardcoreActiveover the tunablewow.hardcore, inbox f83608c3).Tests/roster_hardcore_spec.luano longer installs its own_G.C_GameRulesor deletes it after each example (that would now remove the harness's table until the next reset); it setswow.hardcoreinstead, and gains the no-namespace case.
Added: Hardcore death list in the export (EXPORT-04)
- New
Export:DeathRows(sort)andExport:DeathText(rows, opts)inModules/Export.lua: every record ROSTER-12 marked dead, current and former (a record moves to the former store whole), a name in both stores once as the current record; ordered by date of death (newest first, ties by name, no time last) or by name. Columns Name, Level (the level at death, else the record's), Class, Date of death. - The export window gains a fourth tab, "Hardcore deaths", offered only while
ns.Compat.IsHardcore(), with the delimiter, the headers and a "Sort by" dropdown saved assettings.export.hardcoreSort(default"date", new inSavedVariables.lua). A window left on it opens on Members off a Hardcore realm. Locale keys "Hardcore deaths", "Date of death", "Sort by". - Tests: EX-18/EX-19 in
Tests/export_spec.luared first (run 20261014-132757.260, the functions missing andhardcoreSortunset), EX-20 inTests/export_window_spec.luared first (run 20261014-132849.497).Tests/settings_spec.luaST-01 listshardcoreSort = "date"because the setting is new. Whole suite 1229 passed, 0 failed, 4 pending (run 20261014-133545.125);Modules/Export.lua,GUI/ExportWindow.lua,APICompat.lua,Modules/Roster.lua,GUI/SettingsPanel.lua,SavedVariables.lua,Locale/enUS.luaat 100% (coverage run 20261014-133829.249).docs/design/export.mdhas the decisions and EX-18 to EX-20. Not seen in game.
Changed: days and months start at the realm's midnight, not UTC's
- Peer Review 5d2eb9c4 P2.
ns.Dates.offsetis the realm's zone in seconds from UTC;FromEpochandEpochFromAgo's month and year steps read the realm's calendar, and newDates.EpochOf(date)is the realm's midnight starting a day. So a join or promotion day, a log day, a leave day and aStats:Turnovermonth fall where the realm's clock puts them. - New
ns.Compat.RealmOffset():C_DateAndTime.GetServerTimeLocal() - GetServerTime(), rounded to a quarter hour because the local value refreshes only every 60 seconds (warcraft.wiki.gg, API_C_DateAndTime.GetServerTimeLocal); 0 without the function or past 14 hours. Documented in all five flavour trees (DateAndTimeDocumentation.lua).OnEnablesetsns.Dates.offsetfrom it first. - The GRM import reads GRM's day and time tables on the realm's clock through
Dates.EpochOf(a log entry's time, a former member'sleftAt), so an imported entry shows on GRM's day. - Days already saved as YYYYMMDD are not rewritten: each is the day recorded when it was written, and
rewriting a synced record would give it a new
contentHashfor every officer to fetch. - Known cost: the offset is the realm's zone now, so across a daylight-saving change an epoch within an hour of midnight can land on the neighbouring day, and a change mid-session applies at the next login.
- Tests:
Tests/env_ugr.lua'sloadtakesrealmOffsetand defaults it to 0 rather than the harness's -5 hours, because every UGR fixture is written as a UTC day;env.peersets 0 too. Specs inTests/dates_spec.lua,Tests/apicompat_spec.lua(all five clients) andTests/grm_import_spec.luared first (run 20261014-124620.765, 26 failed); the realm-month spec inTests/stats_spec.luawas written after the code and passed at once. Whole suite 1190 passed, 0 failed, 4 pending (run 20261014-125408.102);Modules/Dates.lua,APICompat.lua,Modules/GRMImport.lua,Core.luaat 100% (coverage run 20261014-125151.229). Not seen in game. - Each path that writes a stored day is now pinned on a realm off UTC (Peer Review T1, thread
6eda7d78):
Tests/roster_tracking_spec.lua's "stored days on a realm off UTC" runs the comparison's arrival, a join, a rejoin, a leave, a promotion andMembers:RankTimeline's leave-day end at UTC-5 and UTC+10, two hours from UTC midnight. They pass against the code; withFromEpochignoring the offset all 12 fail and nothing else in that file does (run 20261014-125939.623). Whole suite 1202 passed, 0 failed, 4 pending (run 20261014-130126.722). GRMImport.lua'sstampOfcomment no longer says an imported entry shows at GRM's hour: the epoch is GRM's day and time on the realm's clock, and the Log draws its time on the player's own (Peer Review T2).
Changed: the export window adds new log entries instead of rebuilding the log
- New
Export:LogRowsAdd(rows, added): the added entriesRenderdraws go in front of the built rows, newest first, the later entry first in a tie, when none is older than the newest row; nil when one is, so the caller builds again.GUI/ExportWindow.lua'sUGR_LOG_ADDEDhandler uses it instead of droppinglogRowson every entry (Peer Review B1, thread deeb7863 reply d625d7c7). Specs inTests/export_spec.lua(EX-17) andTests/export_window_spec.lua(EX-14) red first (run 20261014-121935.180). Whole suite 1161 passed, 0 failed, 4 pending;Modules/Export.lua,GUI/ExportWindow.luaandModules/Roster.luaat 100% (run 20261014-122638.119). The time saved was not measured (Tests/perf/log_perf.luanot run). Export:LogTextwrites no line for an entryRenderno longer draws. Rows the window cached while the player could read officer notes outlive a demotion, and each such entry wrote its time with an empty event. Found in this session's self-audit; spec red first (run 20261014-122929.154), whole suite 1162 passed, 0 failed, 4 pending,Modules/Export.lua176/176 (run 20261014-123132.113). Known cost: until the window is built again, its "Rows x-y of N" still counts the hidden entry.- The export window's
OnClosedropslogRows. It kept them, so after the log set was shown once every later log batch copied the whole cached list for a window that builds it again onOpen(Peer Review P1, thread 04dd90b0). Open on another set holds no rows already: a set switch goes throughRender, which drops them. Spec red first (run 20261014-123622.556); whole suite 1163 passed, 0 failed, 4 pending (run 20261014-123822.223). Modules/Roster.lua's comment above itsGUILD_RANKS_UPDATEhandler said it was the only one in the addon;Alts:EnableandBans:Enableregister their own (Peer Review C1, thread 9f39e230). Comment corrected.
Added: the guild info's thresholds block (SY-35 to SY-40; SYNC-08)
- A block such as
UGR{g=4;c=4;b=1}in the guild info text sets the three sharing thresholds (dates and alts, custom notes, bans) for every client in the guild.Sync.ParseGuildInforeads the first block only; an unknown letter, a value that is not plain digits and a repeated letter are ignored.Sync:GuildLock(kind)keeps a value only when the guild has that rank, so a value naming no rank leaves that threshold to each officer's own setting. Sync:Thresholdreturns the lock ahead of the saved setting, soCanAuthor, the receive gate and the ban host follow it without other changes. The saved setting is kept and applies again once the block drops it. The text is read on every lookup and parsed only when it changed.Settings:SetandSettings:Checkrefuse a locked threshold with "Set in the guild info."; the settings page shows the locked rank, greys its dropdown and gives that sentence as the tooltip. The first wording, "Set by the guild leader in the guild info.", was wrong: whoever passesCanEditGuildInfomay write the block (Peer Review N1, thread 4d99f87a; spec strings changed with it).Sync:CheckGuildInfo()runs onGUILD_ROSTER_UPDATE(the event Blizzard's own guild info frame re-reads the text on, Blizzard_Communities/GuildInfo.lua:28-30) and, when a lock changed, runsSettings:Changed(), so a ban lock above the player stops their ban host.Settings:Changed()is new: the one list of reactions to a changed setting (ban host, settings page, shown tab), called bySettings:Settoo. Before it, the guild info path skippedMainWindow:RefreshActiveTab, so an open tab stayed painted from the old threshold (Peer Review N2, thread 4d99f87a; spec red first, run 20261014-115124.998).GUILD_RANKS_UPDATEnow runsSettings:Changed()too (Modules/Roster.lua). A changed rank list can change a threshold, because a removed rank reads as the lowest, but the event only repainted the settings page here and checked the ban host inSync.lua. It never repainted the shown tab. Found in this session's self-audit; spec red first (run 20261014-120130.652). Whole suite 1155 passed, 0 failed, 4 pending (run 20261014-120320.394);Sync.lua,Modules/Settings.lua,Modules/Roster.lua,GUI/SettingsPanel.luaandLocale/enUS.luaat 100% (run 20261014-120608.785).Sync.luano longer registersGUILD_RANKS_UPDATEon the ban lane:Settings:Changed()already runsCheckBanHostfor it, andCore.lua'sOnEnablealways runsRoster:EnablebeforeSync:Start, so the second handler had no case of its own (Peer Review S1, thread 9f39e230).PLAYER_GUILD_UPDATEstays. Thesync_bans_specrank-list cases still pass through the one path; whole suite 1155 passed, 0 failed, 4 pending (run 20261014-121025.028),Sync.luaandModules/Roster.luaat 100% (run 20261014-121246.006).sync.mdSY-37 now says the lock is as current as the client's copy of the guild info, which Blizzard's frame refreshes with a roster request, and records the known cost that the block carries rank numbers, so adding or removing a rank shifts what a pasted block means (Peer Review, thread 4d99f87a).- Harness pin 05cb0c8 -> b37b60e, which adds
CanEditGuildInfo(inbox 69f13c3b). The SY-40 spec setsguild.canEditGuildInfoinstead of replacing the global; SY-35 reads the harness's global and keeps three replaced-global cases only for the wrapper's own coercion. Whole suite 1154 passed, 0 failed, 4 pending (run 20261014-115939.543). - The Sync section has a "Guild info block" button for a player who may edit the guild info: it shows
Sync:GuildInfoBlock(), the current thresholds as a block, inW:ShowCopyBox. ns.Compat.GuildInfoText()(C_GuildInfo.GetInfoText,""when there is none) andns.Compat.CanEditGuildInfo()(the bare global, present in all five trees). The harness has noCanEditGuildInfo; the specs supply it.- New
Tests/sync_guild_info_spec.lua, red first (run 20261014-114051.593). Whole suite 1153 passed, 0 failed, 4 pending (run 20261014-114402.419);Sync.lua,APICompat.lua,Modules/Settings.lua,GUI/SettingsPanel.luaandLocale/enUS.luaat 100% (run 20261014-114635.204). Not seen in game, and whether Retail hands the guild info text to addons is not verified.
Added: the Sync tab, Sync now and /ugr syncnow (SY-29 to SY-34; SYNC-06, SYNC-07)
GUI/Tabs/Sync.luareplaces the old Sync tab, which listed one row per held record. The tab now lists who in the guild runs the addon: one row per VersionCheck roster row (VC:GetRoster), its four states as "Running", "Not answering", "Offline" and "Unknown" (VersionCheck README.md:205-209), the version tidied (sorted by its numbers, so 0.10.0 follows 0.2.0), and a Records column. The player's own row countsSync:Servable()(see Peer Review below); a guildmate's counts what their last numbered broadcast advertised. The status line still reads "Records shared: %d". A version heard and a broadcast counted repaint the open tab.Sync:OnOfferReceivedis the general host'sonOfferReceived(DeltaSync.lua:1804): anhlb2from a guild member stores#DecodeEntries(data.e)asSync:Advertised(sender); a guild change forgets the counts. The ban host is not counted, because its entries are ban keys and the tab is for every rank.Sync:SyncNow([chat])announces on the general host and on a running ban host, at most once per 30 seconds (GetTime), with VersionCheck'sRequestCheckreturn convention:true;false, seconds left; orfalse, 0, reason(guildless, sync off, not started, broadcast not sent), which starts no clock. The tab's "Sync now" button isW:BindCooldownButtonon the cooldown"UGR-SyncNow"withSync:SyncNowWaitas its seconds; a refusal shows in a one-button dialog over the tab./ugr syncnowprints the outcome in the chat language and starts the same cooldown.GUI/Tabs.lualoses its generic list render: every tab draws itself now, so it had no caller left. It maps each tab key to its module.- Spec changes, each with its reason:
Tests/window_spec.lua's "the Sync tab lists the synced records" now asserts only the status count (the rows are who runs the addon now);Tests/slash_spec.luaandTests/toc_spec.lualistsyncnowandGUI/Tabs/Sync.lua. Locale keys "Record" and "Saved" removed with the old tab. - Specs red first (run 20261014-104155.588); the version-sort spec red with
sortValueremoved (run 20261014-105229.513). Whole suite 1123 passed, 0 failed, 4 pending;Sync.lua,GUI/Tabs/Sync.lua,GUI/Tabs.lua,Slash.luaandLocale/enUS.luaat 100% (run 20261014-105433.148). Not seen in game. - Peer Review (thread 8ae2f42a, reply b91db605), specs red first (run 20261014-110155.582):
- Q2: the player's own row and the status line counted
Sync:Count(), every held record, while a peer's row counts what their broadcast advertised, which is only numbered keys with a servable canon. NewSync:Servable()is#host.p2p:ServableEntries(), DeltaSync's own list (DeltaSyncP2PNumbered.lua:267), so both rows count one thing; 0 before sync starts. - N1: blank Version and Records cells were
"", which RowList sorts before any text, so the first click on either column filled the top with guildmates who had no data. They are nil now, the version sort value is nil for no version, and both columns setnilsLast. - N2:
Sync.advertisedwas keyed by DeltaSync's sender (realm added by LibGuildRoster'sNormalizeName) and read by VersionCheck's row name (which may be bare). Both now go through DeltaSync'sNormalizeSender. - Q6: the version-sort spec read RowList's private
_getSortedData; it reads the drawn cells through the publicCellFrameForinstead. - Q5: a comment at
SyncNow's ban-lane announce says why its result is ignored. - Q3: the tab's content release calls
W:UnbindCooldownButton(LibAceGUIWidgets MINOR 43, asked for on inbox thread 58585085; feature-detected), so a language switch no longer leaves the old Sync now button counting down. Spec red first (run 20261014-111540.794). - Spec changes with their reasons: four
sync_tab_specassertions of""now assert nil (N1);window_spec's status-count spec saves a real record instead of planting one with no number and an unservable canon, which the count now rightly leaves out (Q2). - Whole suite 1127 passed, 0 failed, 4 pending (run 20261014-111725.740, against LibAceGUIWidgets'
unreleased MINOR 43 working copy);
Sync.luaandGUI/Tabs/Sync.luaat 100% (run 20261014-111932.485).
- Q2: the player's own row and the status line counted
- Peer Review (thread b1e1c2d4, reply f9dccf8e):
- F3:
Sync:Count()removed. After Q2 it had no production caller; the specs that used it countpairs(Sync:Records())through a localheld(), which is what they meant by "held". - F4: one Sync tab refresh read
Sync:Servable()twice (the player's row and the status line), and each read walks and sorts every held key. It is read once per refresh and shared, so the two numbers agree by construction. New spec: one refresh callsServableEntriesonce. It was written after the fix, so its red run is the old double call put back (run 20261014-112421.117, expected 1, got 2). - Whole suite 1128 passed, 0 failed, 4 pending (run 20261014-112844.098);
Sync.luaandGUI/Tabs/Sync.luaat 100% (run 20261014-113048.933). Cost of rebuilding on every broadcast heard in a large guild: not measured.
- F3:
- Peer Review (thread b295c480, reply 3461efd7), test helpers only:
- S1: the two
held()copies are oneenv.held(Sync)inTests/env_ugr.lua. - S2:
env.withsaved through__indexand restored by assignment, so an inherited method came back as an own field and a later stub of the class method would not reach that object. It saves withrawgetand restores withrawset. NewTests/env_ugr_spec.lua, red first (run 20261014-113438.265); whole suite 1133 passed, 0 failed, 4 pending (run 20261014-113620.675).
- S1: the two
Fixed: the export window held the keyboard, missed new log entries, and was slow on a big log (Peer Review, thread deeb7863)
- A1: the copy fix below focused the box on every fill, so after any click in the window the player
could not move or use a key binding, and the first Escape only cleared the box's focus (AceGUI's
MultiLineEditBoxsetsOnEscapePressedtoClearFocus). The window no longer takes the focus; gaining it selects the whole text (OnEditFocusGained, Blizzard's idiom for a box taken whole), and a put-back while focused selects again. The label is now "Click the box, then press Ctrl+C to copy." The old Finding 1 spec asserted focus after every fill; it is replaced, reason as above. Not verified in game: whether the client firesOnTextSetinsideSetTextor later. - A3: the guild-change spec now moves
ns.Guildto a second guild with its own member and asserts the text shows that member and not the first guild's. - A4:
RefreshLocaleand theUGR_GUILD_CHANGEDhandler share onerebuild(firstPage); a language switch keeps the page, a guild change returns to page 1. - A5: measured, not argued (
Tests/perf/log_perf.lua, new export case): a redraw of the log set over a 50,000-entry log took 265 ms against the 250 ms budget (run 20261014-102856.788). The window now buildsExport:LogRowsonce per build and reuses it for every choice: build 199 ms, a choice 3 ms (run 20261014-102954.372). A new log entry drops the rows and redraws an open log set, which before this never showed an entry logged while it was open. - Specs red first (runs 20261014-102753.527 and 20261014-102926.429). Whole suite 1091 passed, 0 failed,
4 pending (run 20261014-103221.535);
GUI/ExportWindow.lua217/217.
Fixed: the export window's copy, a guild change, and the log's order (Peer Review, thread 9fec1de3)
- Ctrl+C copied nothing: nothing in the box was ever selected, and AceGUI's
SetTextclears any selection. Every fill of the box (open, a choice, a page, the read-only put-back) now focuses it and selects the whole text (fillinGUI/ExportWindow.lua). Finding 1. - Leaving the guild with the window open left it on the old guild, and the next click raised (no
settings to read).
ExportWindownow listens forUGR_GUILD_CHANGED: it closes guildless and redraws from the new guild otherwise. Finding 2. - The log export walked the log by position, so after a login batch it was not newest first and
disagreed with the Log tab.
Export:LogRowsis now built fromns.EventLog:View(every category, no headers), one ordering for both. It ignores the Log tab's category filter on purpose, now written inexport.md. Finding 3. - Specs red first: the log order, focus and guild-change specs all failed on the old code (desk run 20261014-101612.813). Four EX-17 specs changed their entry times from fixed small numbers to times after now: they relied on the export ordering by position, and the setup logs an alt-link entry at the current time, which a by-time order puts above an entry dated 1000.
- Finding 4 (an item dragged or a link shift-clicked into the box may stay until the next fill) is an
in-game check, written under "Not verified in game" in
export.md.
Added: the export window and /ugr export (EX-09 to EX-15, EX-17; EXPORT-01 to EXPORT-03, SLASH-05)
GUI/ExportWindow.lua(ns.ExportWindow), in all five TOCs afterGUI/ImportDialog.lua: an AceGUIFramewith Members, Former members and Log tabs (LAGW-TabStrip), a delimiter dropdown (;,,, Tab), an Include headers chip, Mains only and Alts only chips, two level steppers, one chip per column with Select all columns, Previous and Next with a "Rows a-b of n" line, and a read-only box to copy from. Every choice is saved in the guild'ssettings.exportand returns to page 1. The box shows each|doubled, asexport.mdrequired. Size and place in the newprofile.exportWindow.Export:LogRowsandExport:LogText: the log data set, newest first, When and Event, officer-note entries left out for a player who may not read them./ugr exportopens it; guildless it says "You are not in a guild."MainWindow:RefreshLocalerelabels an open export window.- Specs: new
Tests/export_window_spec.lua(EX-09 to EX-15) and EX-17 inexport_spec, red first (desk run 20261014-100531.427: the window and log specs failed on the missing module). One spec was corrected after the first green run: it fired a level stepper's callback without the stepper taking the value, which a player's change never does; it now sets the value first, as the chip helper does. - List specs extended for the new file, saved default and command:
toc_spec,savedvariables_spec,slash_spec. Whole suite 1079 passed, 0 failed, 4 pending (desk run 20261014-101000.906); Export, ExportWindow, Slash, MainWindow, SavedVariables and enUS at 100% (desk coverage run 20261014-100754.686). Not seen in game.
Changed: the settings page's switches are toggle tokens (ST-15)
- The operator, 2026-10-14: "can you make all the check boxes on the settings UI be toggle tokens?"
GUI/SettingsPanel.luanamesLAGW-Chipas every toggle'sdialogControland gives it no width, so each token sizes to its label (LibAceGUIWidgets inbox 20d63ff4, chip widget Version 2, MINOR 43). - Feature-detected in
BuildwithAceGUI:GetWidgetVersion("LAGW-Chip") >= 2: an older library would raise on AceConfigDialog's firstSetLabel, so it keeps the check boxes at their old widths. MINOR 43 is not released yet, so players see check boxes until it is. - New ST-15 specs in
settings_panel_spec(the options table, the drawn page through the real AceConfigDialog, and the old-library fallback), red first. Not seen in game.
Changed: a language switch relabels the ban and note dialogs in place
- LibAceGUIWidgets MINOR 43 added the form dialog's
frame:SetLabel(i, text)andframe.labels[i](inbox 1a2fe53f, asked for by UGR).ns.UI.DialogSlottakes a third argument,relabel(frame), and aRelabel()method: with SetLabel it relabels the same frame, so an open dialog keeps what the player typed and no frame or Escape-list name is left behind per switch; without it (an older library) it falls back to the oldRetireand rebuild under the next generation's name. GUI/BanDialog.luabuilds its rows and labels from one list (rowKeys) so build and relabel cannot disagree; the title follows the dialog's state (Ban player or Edit ban).GUI/MemberPanel.luarelabels both note dialogs' labels, Save button, title and the custom note's "Last edited" body in place.- Specs:
bans_tab_specreads labels fromframe.labelsinstead of the library's private_rowHolders; the F4 spec now asserts the same frame and typed text kept (red first). New old-library specs inbans_tab_specandmember_panel_specremove SetLabel from the frame and assert the rebuild; a public-note relabel spec inmember_panel_spec. Not seen in game. - An in-place relabel also clears a refusal on screen (
frame.err), which the old reopen did and the in-place switch left in the old language (Peer Review, thread 325dae6b). Specs in both files, red first.
Changed: every tab's button strip is LibAceGUIWidgets' (UI-17)
- LibAceGUIWidgets MINOR 43 added
W:CreateButtonStrip, which isUI.Stripmoved into the library (inbox 31133c51, asked for by UGR).UI.Stripreturns it when the library has it, and builds its own strip as before when it does not. Same buttons, reasons and status line; the library's strip also scales withW:SetScale, and pins the status line's left edge to the last button, so a long status is clipped instead of drawn over the buttons. - Because the strip now grows with the scale,
UI.Listhangs the list from the strip's bottom edge instead of a fixedSTRIP_Hoffset, which would have put the list over the strip above scale 1. A tab with no second row (the Sync placeholder) now starts its list at the strip's left inset, like the others. MainWindow.MinWidthcounts the strip's 6 inset as scaled with the library strip and as fixed pixels without it.roster_tab_spec's scale-2 case changed for that reason: at the old floor the Custom note drew 194 px, short of 200 (desk run 20261014-094522.987).- Harness pin 07696f5 -> 05cb0c8, which loads
LibAceGUIWidgets-ButtonStrip.lua. NewTests/ui_strip_spec.lua(UI-17), red first. Not seen in game.
Changed: note and ban-reason boxes stop at their limit and count with the library's counter
- The note boxes (31 for a game note, 200 for a custom note) and the ban reason (150) now call
SetMaxLetters, so the client stops the typing at the store's limit. None set a limit before; the store refused an over-long text on Save. - With LibAceGUIWidgets MINOR 43 the count is
W:AttachCharCounter(inbox 31133c51), on the label line, red at the limit, asframe.counter. The note dialogs' hand-drawn count in the row's note is kept for an older library; the ban reason had no count and gets one only from the library. - Specs in
member_panel_spec(the counter, the limits, the old-library row note) andbans_tab_spec, red first. The harness does not refuse a keystroke at the limit and counts bytes, not characters, so the client's own stop and a non-ASCII count are not verified offline. Not seen in game.
Changed: Settings is a gear by the Close button, not a tab (UI-16)
- The operator, 2026-10-14: "so settings shouldn't be a tab, it should be the gear wheel icon next to
the close button like in FGI. it's in widgets."
GUI/MainWindow.luadresses the window's bottom row withW:DressBottomRow(LibAceGUIWidgets README.md:738-767), FastGuildInvite's gear texture and crop; a click isSettingsPanel:Open(). OnClose callsW:UndressBottomRowbefore the release, so the pooled frame carries no gear into another addon's window.RefreshLocalere-dresses it to relabel the tooltip. ns.Constants.TAB_ORDERloses"settings",GUI/Tabs.luaits Settings def,Locale/enUS.luatab_settingsandOpen settings(new keySettings). A savedprofile.activeTab = "settings"is no longer a tab, so the window opens on Roster; no migration needed.- Specs changed for the operator's decision, with the reason in each:
window_spec(five tabs, a list on every tab; the Settings tab's button and no-status specs removed),load_spec(TAB_ORDER),slash_spec(no settings exception). New UI-16 specs inwindow_spec, red first.README.md,CLAUDE.mdanddocs/design/ui.mdupdated; the CurseForge page waits for release prep.
Fixed: the GRM import window's text ran under its buttons
- The preview and the report listed their counts and skip lines over the Import and Cancel buttons
(operator, in game, 2026-10-14).
W:ShowDialoggives every prompt one fixed height (LibAceGUIWidgets-1.0.lua:3849).GUI/ImportDialog.luanow grows the frame after each show to hold the prompt's top inset, the text, a gap and the buttons, using the library's own layout numbers. The library resets the size on its next show, so another addon's prompt is not left tall. - New spec in
Tests/grm_import_spec.lua, red first. It sets the prompt's height itself, because the harness reports one line's height for any text (Tests/wowapi/env/frames.lua:1681).
Added: the guild's stats (SS-01 to SS-05; Later feature, no UI yet)
- New
Modules/Stats.lua(ns.Stats) in all five TOCs afterExport. Pure counting over the current guild; nothing is saved (SS-05, pinned against a deep copy ofUGR_DB, mutant red). Stats:Overview()counts members, mains, alts and unlinked characters (the three sum to members) and spreads them by class token, rank index and level bucket. A level at the client's cap is its own bucket, any other level its ten. A missing class, level or rank is counted under"unknown", so every spread also sums to members.Stats:Activity(now)counts members whose last-seen time is within 7, 30 and 90 days. A member the library knows is read through the newRoster.SeenAt, the readingRoster:Livenow also uses: the storedlastSeenAtis written only when a comparison runs, so it went stale for anyone who came online since (self-audit f8482e31, Peer Review 5d2eb9c4 P1; spec red first).Modules/Dates.lua's header said the realm's offset could not be known. It can, fromC_DateAndTime.GetCurrentCalendarTime; the comment now says UTC is a choice, and UTC months are recorded as a known cost indocs/design/stats.md(Peer Review 5d2eb9c4 P2).Stats:Turnover(months, now)counts joins, leaves, removals and rejoins per UTC calendar month, newest first. Every leave (left,kicked,leftUnknown) counts as a leave and a kick also as a removal. Imported log entries count.- Every call reads the current guild, not a key as
docs/design/stats.mdfirst sketched: the alt view is built for the current guild only. Decisions recorded in the design doc's "As built". Tests/toc_spec.lualistsModules/Stats.lua(a new file was added).
Added: the export's rows and text (EX-01 to EX-08; Later feature, no UI yet)
- New
Modules/Export.lua(ns.Export) in all five TOCs afterMacroQueue.Export:Rows(set, opts)lists current or former members by name, filtered by level range and mains or alts.Export:Text(rows, columns, opts)joins them with the chosen delimiter, a header line first when asked.Export.Pagegives the page's first and last row, clamped at both ends. - A delimiter, line break or carriage return inside a value becomes a space, matched as plain text, so
a
.or%delimiter is never a pattern. The officer note column is offered and written only for a player who may view it. - A former member's role and characters come from what was saved when they left, decided by which store holds the record, so a caller cannot pass former rows as current ones and read a live alt group that still names them (self-audit 50c18e31 E1, Peer Review thread d6507c7e). A birthday whose day or month is not a number writes blank instead of stopping the whole export.
- New per-guild
settings.exportdefaults (delimiter, headers, one switch per column, level range, mains or alts, page size). The column switches are filled fromExport.COLUMNS, the one list. - Spec changes, each because a new saved area or file was added:
Tests/settings_spec.luaST-01 now expects theexportdefaults, andTests/toc_spec.lualistsModules/Export.lua. - Locale keys for the new headers and the birthday pattern
"%m-%d". Decisions the design left open are recorded indocs/design/export.md, "As built".
Added: the macro tool's queue (MC-08 to MC-10; Later feature, no UI yet)
- New
Modules/MacroQueue.lua(ns.MacroQueue), in all five TOCs afterMacroRules, enabled inOnEnableafter the roster. It is session-only and nothing is saved; a guild change empties it, a rename does not. MacroQueue:WhyNot(action, name)refuses what the game would refuse (MC-09). It uses the game's own guild frame's rules (Classic/FriendsFrame.lua:2969-2989in the classic_era tree). The sameCanGuildRemove/CanGuildPromote/CanGuildDemotecalls appear inBlizzard_Communities/GuildRoster.luain all five client trees. The rank needs the permission, and the member must be strictly below the player. A promotion may not reach the player's rank or guild master, and a demotion may not go below the lowest rank. An unknown rank (the player's, the member's, or the rank list) refuses and says which.Addrefuses with that reason, or when the same action on the same member is already pending or sent.StartBatch(n)settles the previous batch, re-checks each item (markedskippedwith its reason if it is now refused), and records the member's live rank and rank name before anything is sent (MC-10).Checkruns onUGR_ROSTER_CHANGESand marks an itemdoneonce the roster shows it.Settle, which the next batch calls, marks the restfailed(MC-08). There is no timer (directive b15a1e31). The known cost: a refused action reads as sent until the officer presses again. A failed item that the roster later shows done turnsdone, so a slow success is not left reading as a failure.ns.Compat.PlayerRankIndex's comment no longer says the 0-base is unverified: classic_eraClassic/FriendsFrame.lua:2912-2989compares it directly withGetGuildRosterInfo's rankIndex.- A done demotion with a rule saves
MacroRules.DemoteNotewith the rank name held before it, as the custom note, keeping the note's share switch. When a retry was sent before the first demotion showed, both land in one check and the note is written once (spec red against a mutant ofDemoteNote's already-present check; Peer Review thread 695743e0). - New
ns.Compat.CanGuildAct(action)over the three bare globals;.luarc.jsondeclares them. Twelve new locale strings for the refusals. Tests/macro_queue_spec.lua, 18 specs, red first (run 20261014-074930.942: the module did not exist). Rank changes are driven by the server's demotion chat line, which is how LibGuildRoster learns them (LibGuildRoster-1.0.lua:2877-2904); a roster rescan alone did not move the library's rank. A mutant that read the live rank name for the note went red (run 20261014-075515.905). The spec first installedCanGuild*itself; WoWAPITesting delivered them (inbox 2c103638), the harness pin moved abdc05e -> 07696f5 and the spec now setsguild.canGuildRemove/canGuildPromote/canGuildDemoteinstead (whole suite 984/0/4, run 20261014-081341.043; pointer not committed).Tests/toc_spec.lualists the new file. The failed-to-done spec was red first (run 20261014-075743.050). Whole suite 979 passed, 0 failed, 4 pending (run 20261014-075856.474);MacroQueue.lua,APICompat.lua,Core.luaandenUS.luaat 100% (run 20261014-075504.966).- Peer Review, thread 748b97f1 / b5098e5c, each red first:
- A promotion or demotion could be carried out twice for one rule match.
Addrefused a copy only while the first waspending, so a second could be queued while the first wassentand the roster still showed the old rank, and the member moved two ranks.Addnow also refuses while the same action on the same member issent; afailedone may still be retried. WhenCheckturns an itemdone, a pending retry of it isskippedwith "%s already had that done." (run 20261014-080611.290 red). - A guild rename no longer empties the queue: the handler compares
ns.Guild:Data()with the table the queue was filled in, asModules/Roster.luadoes, so a rename keeps it and a real change empties it (run 20261014-080142.570 red). - A demotion whose custom note is refused stays
done(the demotion happened) and keepsSetCustomNote's reason asitem.noteWhy, for the tab to show (same red run). - Whole suite 984 passed, 0 failed, 4 pending (run 20261014-080832.590);
MacroQueue.luaandenUS.luaat 100% (run 20261014-080945.550).
- A promotion or demotion could be carried out twice for one rule match.
docs/design/macro-tool.md: "Queue requirements" became "The queue (built 2026-10-14)". The Shared rules section no longer saysSaveRulesuses the general threshold; it uses the ban threshold, MACRO-06 (Peer Review, thread 356f3036).
Added: the macro tool's shared rules (MC-06; Later feature, no UI yet)
MacroRules:SaveRules(action, rules)saves a copy of an action's rule list as onerules:<action>sync record throughSync:SaveRecord; the newrulesapplier stores it asglobal.guilds[key].macro.rules[action](new AceDB defaultmacro = { rules = {} }, no migration: nothing was saved there before), read byMacroRules:Rules(action).- Rules are gated at the ban rank, not the general rank:
Sync.ClassOf("rules")is"ban", soSaveRecordrefuses a player below it with "Your rank cannot change this." andAcceptsrefuses a received rules record from a sender below it (Peer Review P1, thread 83f02ad9). The first cut used the general rank, which defaults to the lowest rank, so any member could have rewritten the rule lists every officer's queue reads.macro-tool.mdMACRO-06 andsync.md's record-key table corrected. MacroRules:Rules(action)returns a copy: the stored list is the synced record's own table, and an editor changing it in place would leave the record'scontentHashdescribing different contents (Peer Review P2, same thread).Tests/macro_rules_sync_spec.luanow loads a three-rank guild with the sender as a ranked member, in place of stubbingIsInGuild, because the gate reads the sender's rank. Ban-rank specs red first (run 20261014-073938.997: ClassOf gave "general", the low player saved, Rules handed out the stored table, the low sender was stored).Tests/sync_two_peers_spec.luacarries a rule naming rank 0 across the real serializer and checks the key arrives as the number 0; it passed when written (a regression guard). Whole suite 961 passed, 0 failed, 4 pending (run 20261014-074058.264);Sync.luaandModules/MacroRules.lua100% (run 20261014-074211.881).- Every rule field's type is checked before a list is saved or taken from a peer
(
MacroRules.ValidRule,ValidRecord; Peer Review, thread c87f38f4).Sync.KEYS.rulesreturns nil for an invalid record, so Sync refuses it before storing it; Sync's KEYS comment now says a nil key refuses. Before this, a peer'sinactiveDays = "90"would have raised a Lua error inEvaluate. - A rule carrying a field this version does not know now matches nobody, as one with an
UNBUILTfield does, so a later version's condition is never silently dropped (usable()reads the rule's own fields instead of theUNBUILTlist). - New locale string "That rule cannot be saved.".
Tests/macro_rules_sync_spec.lua, red first (run 20261014-073326.550: the functions did not exist). Whole suite 957 passed, 0 failed, 4 pending (run 20261014-073501.524);Modules/MacroRules.lua,Sync.luaandSavedVariables.lua100% line coverage (run 20261014-073605.652).
Added: the macro tool's rule matching (MC-01 to MC-05; Later feature, no UI yet)
Modules/MacroRules.lua(ns.MacroRules), in all five TOCs afterModules/GRMImport.lua.MacroRules.Evaluate(action, subject, rules, now)returns the first rule a member matches and the reason to show;MacroRules.DemoteNote(record, rule, oldRankName)builds the custom note to save after a demotion. Both are pure: the queue that will call them, the rule editor and the secure button are not built, so nothing a player does reaches this code yet.- Decided in
docs/design/macro-tool.md("Matching"), where the sketch left it open: a rule with no condition matches nobody; a member or alt never seen is never inactive;"mains"takes a character in no alt group, as the Roster tab's chips do; the demotion note is measured in characters, as the custom note editor cuts it. - Peer Review 300c6a98, specs red first (run 20261014-072029.553), then green:
- A record with no level never matches a rule with
minLevelormaxLevel. It was counted as level 0, so amaxLevelremove rule took a member whose level was not known. - The safe tag is looked for in the officer note; the public and custom notes only when the rule
sets
safeTagAllNotes. The first spec accepted all three notes by default, and was changed for this reason: a member who may write their own public note could exempt themselves from removal. A first fix kept the custom note on by default as officer-only; the session's self-audit foundsync.customNoteRankdefaults to the lowest rank (Sync:Threshold), so any member may write a custom note, and it moved behind the same switch (spec red first, run 20261014-072608.161). DemoteNotetakes the old rank name as an argument instead of readingrecord.rankName, which holds the new rank once the roster refreshes. The existing DemoteNote specs pass the rank now.MacroRules.CONDITIONS,NOT_CONDITIONSandUNBUILTclass every rule field; a spec reads the field list from macro-tool.md and fails on a field in no list or two, so a new field forces the decision. A rule setting anUNBUILTfield matches nobody rather than ignoring it. macro-tool.md's field line gainsnameandsafeTagAllNotes, and "Queue requirements" (MC-09 permission check, MC-10 old rank name) records what the queue must do when it is built.- From the self-audit: a rule setting
allAltsInactivewithoutinactiveDaysmatches nobody; the switch had been dropped silently, running the rule wider than asked (spec red first, run 20261014-072831.424). - Whole suite 941 passed, 0 failed, 4 pending (run 20261014-072925.451);
Modules/MacroRules.lua100% line coverage.
- A record with no level never matches a rule with
Tests/macro_rules_spec.lua, red first (run 20261014-071159.254: the module did not exist). The character-count case was not seen red before the code had it, so it was checked by putting the byte count back (red, run 20261014-071457.384). Two locale keys added.Tests/toc_spec.lua's file order gains the new module (the reason for that spec change). Whole suite 934 passed, 0 failed, 4 pending (run 20261014-071449.604);Modules/MacroRules.luaandLocale/enUS.lua100% line coverage (run 20261014-071553.848).
Fixed: anyone outside the guild could whisper in a shared record (SY-09)
Sync:OnDataReceivedcheckedIsMemberonly on a live push. A catch-up reply (noliveflag) or an "unchanged" answer went straight on, and DeltaSync's transport forwards DATA from anyone, leaving the gate to the host (P2PSession.lua:62-66). At the default thresholdSync:Acceptstakes anyone without a rank lookup, so a player outside the guild could whisper a reply-shaped message and have the record stored, applied, and served on to every guildmate who caught up from us; an "unchanged" answer from them completed a P2P item. Every received message is now gated onSync:IsMemberfirst. The ban lane was not affected: itsIsValidPeergates every message before this.- SY-09 rewritten for the sender gate decided earlier today (
Sync:Accepts); the author-rankAcceptit was written against is not built. New spec inTests/sync_records_spec.lua, red first (run 20261014-065404.925). - A record of a kind this version does not know (no
Sync.KEYSentry) is refused on receive, live or catch-up, before the rank gate. I dropped this clause withAcceptand Peer Review asked for it back (thread 761f70fb):Acceptsjudges the sender, so an old client storing a kind it cannot judge would relay a later version's stricter kind past its threshold, through any officer still on this code. Spec red first (run 20261014-070206.577). Cost: an older client neither holds nor relays a newer client's kinds. - Spec changes, each for the same reason (the gate now covers catch-up, and these senders were not in
the test roster):
Tests/sync_spec.lua"a received record" and "route the host's data callbacks",Tests/sync_records_spec.luaSync.APPLIERS, and twoTests/alts_spec.luareceive specs now make the sender a guild member, that one sender only, so a non-member added to those specs later still meets the real gate (thread 761f70fb). With the kind refusal, specs that received records of a made-up kind (test,motd,x, or none) now register that kind inSync.KEYSfirst; the sync-off receive spec now sends a member's realjoinrecord, so the switch is the only thing refusing it. Whole suite 910 passed, 0 failed, 4 pending (run 20261014-070619.193);Sync.lua100% line coverage (run 20261014-070724.343).
Fixed: "Only send to ranks at or above mine" did nothing (SY-14)
- The setting
sync.limitSendToMyRankwas saved and shown on the settings page, butSync:IsValidPeernever read it, so the player synced with every guild member whatever the switch said. With it on, a peer is now a member at or above the player's own rank (an equal or lower rank index); an unknown rank, theirs or the player's, refuses, as the ban lane's rank check does. Specs first inTests/sync_spec.lua("peers", SY-14), red before the change (run 20261014-062018.053); whole suite 891 passed, 0 failed, 4 pending (run 20261014-062116.101). - The live push still reached every rank: every non-ban edit went out on a GUILD broadcast, which never
consults
IsValidPeer, so the switch only narrowed catch-up (Peer Review, thread f05adc36). With the switch on,Sync:Pushnow whispers each online memberIsValidPeertakes, as the ban lane does, and returns how many; with it off it broadcasts as before. A player at the guild's lowest rank still broadcasts once: every member qualifies, so the whispers would reach the same people one message each (self-audit; spec red first, run 20261014-063502.461; suite 902/0/4 andSync.lua100%, run 20261014-063607.521). - Receiving is no longer limited. The first cut also gated the live receive on
IsValidPeer, so with the switch on the player took nothing live from a lower rank; the setting is about sending. NewSync:IsMember(a guild member while sync is on) is the live receive's gate;IsValidPeerisIsMemberplus the rank limit. Specs for both halves inTests/sync_records_spec.lua, red first (run 20261014-062921.146); whole suite 901 passed, 0 failed, 4 pending (run 20261014-063038.039);Sync.lua100% line coverage (run 20261014-063150.971). - KNOWN COST: DeltaSync consults its one
isValidPeercallback both to serve and to fetch (DeltaSyncP2PNumbered.lua:433, :1314), so with the switch on catch-up still stops fetching from a lower rank. A serve-only gate is requested of DeltaSync; until then a lower rank's edits reach the player only by live push.
Sync: UGR_SYNC_APPLIED (SY-12, SY-11)
Sync:OnDataReceivednow sendsUGR_SYNC_APPLIED(key, record)after an incoming record wins and is applied, as sync.md's Messages line has always said; nothing sent it before. A losing record and a localSaveRecordsend nothing. Spec inTests/sync_records_spec.lua, red first (run 20261014-064317.293).- SY-11 (a live push touches no P2P session) was already pinned by the live-push spec in the same file; recorded in sync.md.
- A ban that wins on the ban lane is announced on the same message, with its
"ban:"key, and a ban from a sender below the ban rank announces nothing (Peer Review, thread 47f8c0f9). Pinned inTests/sync_bans_spec.lua; it passed when written, since the ban lane reaches the same receive code. sync.md's Messages line says so.
Sync: the GRM import's save (SY-07, SY-08)
- sync.md specified a
Sync:SaveImported; the import was built onSync:SaveRecordsinstead (grm-import.md, "As built"), so SY-07 and SY-08 are pinned on that path. Spec change reason: the function they named does not exist by a settled build decision, not because the code differs. - New SY-07 spec in
Tests/grm_import_spec.lua: the import stores and numbers its shared records and pushes none live. It passed when written; a mutant passingpush = truefromImport:Runturned it red (run 20261014-064902.600). The"older"half was already IM-08 and IM-09. - New SY-08 spec in
Tests/sync_spec.lua: a plantedcontentHashon aSaveRecordsrow is replaced by the hash of the record. It passed when written (Sync:Stampclears the field before hashing). - sync.md and grm-import.md no longer name
SaveImportedas a function to call.
Sync: a removal and a refused record (SY-13, SY-10)
- SY-13 written in
Tests/sync_records_spec.lua: a newerremoved = truerecord replaces the held one and reaches the applier; an older one is ignored; a stale copy of the removed record cannot come back. It passed when written: a removal goes through the sameSync:Receiveas any record. - SY-10 was already pinned by the existing refusal specs (wrong key, wrong kind, rank gate, non-member live push), each asserting nothing stored and nothing applied; labelled and recorded in sync.md.
- SY-16 written in
Tests/sync_two_peers_spec.lua: a custom note of~|~?|reaches the other client byte for byte, with the author'ssetAtandcontentHash, by live push and by login catch-up. It passed when written (run 20261014-063803.834). The harness wire does not check message text; the live rule permits|(warcraft.wiki.gg,C_ChatInfo.SendAddonMessage: "All characters (decimal ID 1-255) are permissible except NULL (ASCII 0)"; Peer Review, thread 6df7757b). Not observed in game.
Sync: one place computes a hash (SY-04)
- New static spec
Tests/sync_hash_scan_spec.lua: over every file the TOC loads, tokenized, exactly one assignment tocontentHashcomputes a value, and it is inSync:Stamp. It catchesx.contentHash =,x["contentHash"] =, a table-constructor field andrawset; a barenilclears and is allowed; anything unsure counts as computing. It passed against the code as it was, so it was shown red once on a mutant (Bans.lua's copy keeping the old hash; run 20261014-062457.580, namingModules/Bans.lua:95 in copy). Whole suite 897 passed, 0 failed, 4 pending (run 20261014-062553.066). - The Lua tokenizer moved out of
Tests/locale_strings_spec.luaintoTests/lua_tokens.lua, so both static scans read code through one tokenizer.
Slash commands for each tab (UI-12 to UI-14, SLASH-01, SLASH-02)
/ugr configopens the settings exactly as/ugr settingsdoes./ugr roster,/ugr log,/ugr altsand/ugr syncopen the window on that tab and print nothing. Specs first inTests/slash_spec.lua(UI-12, UI-13), red before the commands existed (run 20261014-054503.840).- UI-14: a spec that every registered command's description is a key
Locale/enUS.luadefines. It passed before any code change, because every description already was one; it guards the rule. /ugr bansnow describes itself as "Open the Bans tab.", matching the other tab words; the old key "Open the ban list." is gone.
Fixed: a date cell with no date raised an error while drawing (Peer Review H1, thread 2bd58719)
- W.RowList hands a column formatter
""for a nil cell, not nil (RowList:_cellText), so everyt and ...guard in our formatters always passed and""reacheddate()orDates.Unpack. Rows that reach it, per Peer Review's reading on thread 2bd58719 (Members.lua:266,GRMImport.lua:296; not traced here): a member whose join date was cleared (Joined), a former member imported from GRM with noleftAt(Left). Also an offline member with no last-online time (Last online, Roster and Alts tabs). The Bans tab's Banned and the Log tab's When had the same formatter shape. - One guard, in
GUI/UI.lua:UI.Stampanswers""for anything but a number, and the newUI.Daydoes the same for a stored day.UI.LastOnline(the Last online cell) andUI.LastSeen(an online member sorts as now) replace the copies the Roster and Alts tabs each had. All five sites use them. - The specs had called each formatter with the row's own field (nil), which is why this was not seen.
New specs read the cell as DRAWN (
rl:CellFrameFor(...)). They failed withbad argument #2 to 'date'before the fix (run 20261014-054733.365). The Bans and Log checks call the formatter with""and draw no row; each spec says so.
Fixed: a tab was built before its frame was placed, so no tab list drew a row offline
MainWindow:ShowTabcalledns.Tabs.Renderon a new content frame before attaching it to the TabGroup.UI.FilterRow'sfitread a width of 0 while building, so the filter row, and the list anchored under it, had no rect, and RowList laid out no rows.frames.whyUnplacednamed it: "is anchored to an anonymous Frame, which has SetWidth(0) and no anchors that span that axis" (run 20261014-055434.538). The frame is now placed first, then rendered. In game a laterOnSizeChangedprobably refitted the row once the frame was attached (not verified in game).- My first draft of the drawn-cell specs hand-set the list area's height and said the harness answers 0
for an anchored frame. That was wrong: the harness derives height from anchors, and the 0 came from
the order above. Peer Review caught it (thread 2bd58719). The hand-set height is gone; the specs now
assert
frames.whyUnplaced(list area)is nil.
Main window size (UI-04)
profile.windowis kept throughW:PersistWindow(LibAceGUIWidgets' replacement for a bareSetStatusTable), with a floor ofMainWindow.MIN_WIDTHxMIN_HEIGHT. A saved size under the floor is raised on open; a release forgets the window. Spec inTests/window_spec.lua, red before the change (run 20261014-055400.689). The last tab was already kept inprofile.activeTab.- The width floor is derived, not picked (Peer Review, thread a532a13f, reply 755d24ff). My first
floor, 600, was narrower than the officer Roster view's fixed columns (745 px), and so was the old
default width, 760: RowList gives the width-less Custom note column what is left, so it had 0 px.
MIN_WIDTHis nowns.Tabs.Roster.FixedWidth()(the widest view's fixed widths and its column count) plus RowList's left pad and per-column gap, 100 px for the Custom note, and the Frame, TabGroup, strip and scrollbar insets, each cited inGUI/MainWindow.lua: 965 today. The window opens at that width by default (PersistWindow'swidthoption), soprofile.window's default is now empty and the size is kept in one place;Tests/savedvariables_spec.luachanged with that reason.MIN_HEIGHT360 is still a choice: room for the strip, the filter row, the header and a few rows, not measured in game. - New
Tests/roster_tab_spec.luacases measure the drawn Custom note (RowList:CellWidth) in both officer views at the floor and at the default size, with each header's text width declared as 0 because the harness's text metrics are deliberately not real (so a header wider than its column in game, which RowList widens to fit, is not covered). Red at 600 (run 20261014-060355.533), green after (run 20261014-060346.754). Whole suite 887 passed / 0 failed / 4 pending (run 20261014-060546.059);MainWindow.lua,Tabs/Roster.luaandSavedVariables.luaat 100% (coverage run 20261014-060703.718). - The floor follows LibAceGUIWidgets' scale without doubling the chrome (Peer Review, thread
a532a13f, reply 3127b1ca). PersistWindow multiplies a number floor by the library scale, but only the
list's part (
MainWindow.LIST_WIDTH: fixed columns, pad, gaps, Custom note) is scaled; the AceGUI insets, the strip's inset and RowList's scrollbar lane stay at pixel size. The floor is now the functionMainWindow.MinWidth(scale),LIST_WIDTH + CHROME / scale, the form the library documents for a floor that is partly fixed art, so at scalesit comes back asLIST_WIDTH * s + CHROME.FixedWidthnow takes the per-column gap and returns the widest view's total. A third roster-tab case opens at scale 2 on a 1920x1080 screen and expects exactlyLIST_WIDTH * 2 + CHROME; red with the number floor (1930, clamped to the screen's 1920; run 20261014-061430.871), green with the function (run 20261014-061423.411). Whole suite 888 / 0 / 4 (run 20261014-061530.524);MainWindow.luaandTabs/Roster.luaat 100% (coverage run 20261014-061645.578). - Known cost, not fixed: PersistWindow ends with
W:ClampWindow, which shrinks a window to fit the screen (LibAceGUIWidgets-1.0.lua:1650-1651). On a screen narrower than the floor (for example scale 2 on a 1024-wide UI) the window is drawn narrower than the floor and the Custom note column gets less than its 100 px, or none. Not seen in game.
Slash: every tab has a word
- A spec checks that every
Constants.TAB_ORDERkey exceptsettingshas a registered/ugrword that opens that tab, so a new tab cannot be added without one (Peer Review, thread a532a13f). Shown red by removing thesyncline once (run 20261014-055525.982). Whole suite 885 passed / 0 failed / 4 pending (run 20261014-055624.142);GUI/MainWindow.luaandSlash.lua100% (run 20261014-055737.132).
Roster tab: a keystroke drew the old rows first (Peer Review, thread 2bd58719, item 2)
- The tab called
list:SetSorton every paint, andSetSortredraws beforeSetDatahands over the new rows. It is now called only when the sort key or direction differs from the list's. A spec counts the calls during one keystroke; it failed against the old line (run 20261014-054834.275). - Whole suite 883 passed / 0 failed / 4 pending (run 20261014-054930.709);
GUI/UI.lua, the Roster, Alts, Bans and Log tabs,Slash.luaandLocale/enUS.luaat 100% line coverage (run 20261014-055030.765). Not seen in game.
Roster tab (UI-02, UI-06 to UI-11, docs/design/ui.md)
- New
GUI/Tabs/Roster.lua(ns.Tabs.Roster), in all five TOCs first afterGUI/Tabs.lua; the placeholder def inGUI/Tabs.luais now a one-linerender(and its unusednonehelper is gone). Specs first, in the newTests/roster_tab_spec.lua, run red (12 of 12, run 20261014-053221.680) before the tab existed. - Columns: Name (class colour), Level, Rank (sorts by rank index, draws the rank name), Last online,
Main/Alt, Joined (
Members:CurrentJoinDatethroughns.Dates.Format), Public note, Officer note (only whilens.Compat.CanViewOfficerNote(), rebuilt withrl:SetColumnson every repaint, so a demotion drops it), Custom note. Notes are read throughMembers:NoteFor, as the member panel reads them. externalSort: the tab orders the rows itself, by the column, then rank index ascending in both directions, then short name. Grouped rows sort inside their group.- Search over names (short and full), public note, custom note, and the officer note only while it is
viewable, every word in any order (
W:SearchMatch, the Log and Alts tabs' rule). - Chips (
W:AddChips): Mains, Alts, Offline, Group alts under main, Former members. The former list has Left and Reason columns; Reason is the member's newest leave entry in the event log. Every chip is disabled guildless, and the first four while Former is on. char.roster(newSavedVariablesdefault, per character as ui.md lists) holds the chips and the sort, so a rebuilt tab reads them back.Tests/settings_spec.luaST-02's pinnedchardefaults andTests/toc_spec.lua's file order were updated to match.- The tab repaints on
UGR_ALTS_CHANGEDandUGR_LOG_ADDEDwhile showing. A left click opens the member panel (a group heading opens its main); right-click stays free for the macro tool. - New locale keys: "Search the roster", "Mains", "Offline", "Group alts under main", "Former members", "Not linked", "Main/Alt", "You are not in a guild.", "Left the guild", "Removed by %s", "Removed".
- Spec correction while going green: the first example assumed a member with no join date, but the first roster read gives every member one; the fixture now empties Dan's history so the blank case is real.
- Self-audit fix: a former member with no leave in the log had Reason
"", which sorted first where every other blank sorts last; it is nil now. The spec asserting""was changed for that reason, and the new assertion failed against the old line (run 20261014-054201.111) before the fix went back in. - Whole suite 873 passed / 0 failed / 4 pending (run 20261014-054252.271);
GUI/Tabs/Roster.lua100% line coverage. Not seen in game.
Member detail panel (MEMBER-02, MD-20 to MD-28, docs/design/member-details.md)
- New
GUI/MemberPanel.lua(ns.MemberPanel), in all five TOCs afterGUI/BanDialog.lua: an AceGUIClearFrame(LibAceGUIWidgets) docked to the main window withW:DockWindow, its position saved inprofile.memberPanel. It draws the name in class colour (RAID_CLASS_COLORS, a new.luarc.jsonglobal, present in every flavour's tree), level and rank marked Main or Alt, the live status (or the day a former member left), a ban warning, the join and promotion dates with their history on hover, the public, officer and custom notes, and the alts. The officer note row exists only with permission to view it. - A right-click on a date opens its menu: Set (the date editor,
LAGW-DatePicker), Confirm, Clear, Mark as unknown, and for the join date Copy to alt group in its three modes. A greyed entry carries the store's own reason: newMembers:WhyNotEdit,WhyNotVerifyandWhyNotCopyanswer what the edit would refuse, and the verifiers andCopyJoinDateToGroupnow ask them too, so the menu and the store cannot disagree (Peer Review F1, thread 15653960; specs red first, desk run 20261014-050639.451). Members:WhyNotVerify(soVerifyJoinDateandVerifyPromotionDate) refuses a date already verified, with "This date is already verified.", and re-stamps and saves nothing. Before, a second verify overwrote who verified it first and saved the record again:Sync:Stampgives every save the realm's current time, so re-verifying a stale date made a newer record that would win over a colleague's correction. The panel's own copy of this rule is gone; it asks the store (Peer Review A1, thread 1b76d611; spec red first, desk run 20261014-051600.427).- A click on a note row opens its editor, a
W:CreateFormDialog.Members:WhyNotGameNoteis asked before the editor opens and again bySetGameNote. The public and officer notes show the text just written, marked "waiting for the game", until the roster comparison records the note (Members:NoteFor,Members:NoteLanded). - Escape closes the open editor, then the panel, then the main window (
W:EscapeLayer). A language switch relabels the panel and its editors (MainWindow:RefreshLocalecallsMemberPanel:RefreshLocale). A left-click on a member's row in the Alts tab opens the panel. - Waiting notes (Peer Review N1 and F3, thread ab3bc972): they are keyed by the record's name, cleared
by the roster comparison when it records that note, whoever wrote it (so a later officer's note is not
hidden), dropped when the member leaves, carried through a rename, and wiped on
UGR_GUILD_CHANGED. Four of the fiveNoteForspecs failed first; the first passed on the old code too. - New
ns.UI.DialogSlot: a form dialog a language switch can rebuild under a new global name, since the dialog cannot change a row label after build (LibAceGUIWidgets request 1a2fe53f). The ban dialog moved onto it, and the note dialogs use it, so the workaround exists once. - The own-note test in
WhyNotGameNotecompares the record's name, closing the last item of Peer Review thread aa8b112c. - Harness pin 6f9ae02 to b0f2cfe: the harness models
CanEditPublicNoteandC_GuildInfo.SetNote(guild.canEditPublicNote,guild.noteWrites), soTests/env_ugr.lua's stand-in for them is deleted andmembers_spec.luauses the harness's. - Harness pin b0f2cfe to abdc05e: the harness ships
CloseSpecialWindowsandframes.pressEscape()(a26b6c5, request 0984d7cc), soTests/env_ugr.lua'spressEscapestand-in is deleted andmember_panel_spec.luaMD-25 presses Escape through the harness. Whole suite 860 passed / 0 failed / 4 pending on the new pin (desk run 20261014-052653.443). Tests/member_panel_spec.luasetup changed once, with its reason in the file: officer-note view permission is granted before the load, since the roster read is what records the officer note, and the below-the-threshold examples set the threshold they rely on. Asserted behaviour is unchanged.- An open date menu closes when the panel redraws; LibAceGUIWidgets closes a menu when its anchor hides, and a spec pins that the panel keeps relying on it (Peer Review F2).
- Known costs: a note write the server silently refuses stays "waiting for the game" until that note
next changes or the session ends; a language switch while a note editor is open, after the player
lost permission, closes the editor and loses the typed text. Whole suite 859 passed / 0 failed / 4
pending (desk run 20261014-050815.769);
Modules/Members.luaandGUI/MemberPanel.luaat 100% (desk coverage run 20261014-050926.105). Nothing here has been seen in game.
Member details, the store half (MEMBER-03 to MEMBER-07, docs/design/member-details.md)
Modules/Members.luagains the officer edits, each returning true or false plus the message to show:SetJoinDate/VerifyJoinDate/ClearJoinDate/SetJoinUnknownand the four promotion equivalents,CopyJoinDateToGroup("earliest","main","this", oneSaveRecordsbatch pushed live),CurrentJoinDate/CurrentPromotionDate,TimesInGuildandRankTimeline. A correction to a verified date goes in front of it as a new"edited"entry, so the old date stays in the history and is not counted as another join.SetCustomNote: 200 characters, stamped with who and when, shared ascnote:<guid>only while sharing is on; turning sharing off shares a removal. Gated on the custom-note rank; a changed text is logged.SetGameNotewrites a public or officer note through the game (ns.Compat.SetNote, 31 characters counted withstrlenutf8). It records and logs nothing itself:SetNoteconfirms nothing, so the roster comparison records the change once the game's roster carries it (Peer Review N1, thread aa8b112c). Every call writes, even text the stored note already holds: the stored note lags a write, so a "same text, skip it" test turned "new" then back to "old" into a skipped write (spec red first, desk run 20261014-043337.136). The officer note needs view as well as edit permission.ns.Compat.CanWriteNotesis true on Era, TBC and Mists only, until note writing is checked in game on Retail and Forever.- The
join,promoandcnotesync appliers: only a received record is applied, only under its own key, and only to a member held by GUID. A received removal (another officer's Clear) also clears a client's own unverified, auto-detected date; decided in member-details.md. - New
ns.Compatwrappers:CanEditOfficerNote,CanEditPublicNote,CanWriteNotes,SetNote.CanEditPublicNoteis a new.luarc.jsonglobal. The harness models both it andC_GuildInfo.SetNotesince the pin moved to b0f2cfe (see "Member detail panel" above), and the stand-in this line used to describe is deleted. Tests/members_spec.lua(MD-06 to MD-19), red first.sync_two_peers_spec.luaandsync_records_spec.luachanged: their samplejoin/promo/cnoterecords now carry theguidtheir key names, because those kinds now have key rules that refuse a record filed under another member.- Self-audit fixes (thread aa8b112c): a new join entry no longer carries a rank index; the note length
check uses the game's
strlenutf8, as Bans does, instead of a second hand-written counter. Whole suite 820 passed / 0 failed / 4 pending, Members, APICompat and enUS at 100% (desk coverage run 20261014-043440.185). - Members are keyed by GUID for sync, and LibGuildRoster does not supply GUIDs yet (request 2f8b405d): until it does, dates and custom notes stay on the officer's own client and every game note edit is refused. Nothing here has been seen in game, and the member panel that calls it is not built.
Import from Guild Roster Manager (IMPORT-01 to IMPORT-05, docs/design/grm-import.md)
- New
Modules/GRMImport.lua(ns.Import):Findreads the current guild's entries from GRM's saved tables (members, former members, the log, alt groups; a faction-level save too), writing nothing.Planis pure and returns the counts and skips;Runplans again against what UGR holds at that moment, then writes.Statusreads the guild's import marker,global.guilds[key].import. - What comes across: members and former members (join and rank history merged and de-duplicated, the
GUID, birthday and the leaving details a held record lacks; a custom note only when newer), the
newest verified join and promotion dates and shareable custom notes as
join:/promo:/cnote:records, alt groups asalt:grm:<group>:<edit time>records, bans and unbans at the ban rank, and the log once per guild. Log types 3, 4, 8, 14 and 19 become UGR entries; any other keeps GRM's saved line as animportedentry. - Every shared record keeps GRM's own edit time as
setAt(decision 1) and goes throughSync:SaveRecordsin one batch with no live push; catch-up carries it to other officers. - Anything UGR already holds at the same or a newer time is skipped as older, so a second run brings nothing. Unreadable entries are skipped and counted, never fatal.
- Only an officer (LibGuildRoster
IsOfficer) may import; bans also need the ban rank, and every other shared record needs the officer's rank to author it ("rank too low", Peer Review P2, thread f596b0c0; spec IM-12b red first, desk run 20261014-035629.114). - While the guild's ranks are still loading,
Import:WhyNotreturns Sync's wait message ("The guild's ranks have not loaded yet. Try again in a moment.") for the preview andRunalike. Before, an import run right after login planned against a missing rank threshold and reported every shared record as "rank too low" and every ban as "not permitted", both false (Peer Review A2, thread 7da28d2c; spec IM-18b red first, desk run 20261014-040639.932, green 20261014-040650.124). - New
GUI/ImportDialog.lua: the first-time offer after the login comparison, once per guild, and the preview from/ugr import(new slash command), which shows the counts and skip reasons and then the report.Members:AddFormerandEventLog:AddImportedadded for it; imported entries are never said in chat. - All five TOCs list the two new files and
## OptionalDeps: Guild_Roster_Manager, so GRM's saved data is loaded first when GRM is enabled. Tests/grm_import_spec.lua(IM-01 to IM-20), its fixture keyed exactly as a real GRM save file (version R1.99422) with invented players. Red first (desk run 20261014-034703.201:ns.Importmissing), green 20261014-035148.104.toc_spec.luanow expects the two files and the optional dependency, andslash_spec.lualistsimportamong the built-in commands.- Not verified, none of it seen in game: reading a disabled GRM's data, the clock GRM's times are in
(read as UTC), how GRM lays out log types 7, 10, 1, 2, 6, 5, 11, 9 and 24, the banned-stranger rule
(rank index 99), and filled
altsAtTimeOfLeaving. Re-importing after GRM edits an alt group makes a second group, because the group's id includes its edit time.
General live push (SY-05, SY-11)
Sync:Pushnow sends any record that is not a ban once on the general host's DATA broadcast ({ type, record, live = true }, GUILD by DeltaSync's default), exactly as stored, its hash forwarded. Nothing is sent while guildless, before the host starts, or while sync is off. It returns true or false for these, and still the whisper count for a ban.Sync:SaveRecordpushes every record it saves, after storing, numbering and applying it; before, only bans were pushed.Sync:SaveRecords(batch, push)pushes each stored entry after the appliers whenpushis true. Alts saves every edit through it withpushset; the GRM import will leave it off, as catch-up carries an import. Until this, alt edits went out only at the nextAnnounce, and the live push above had no production caller (Peer Review N2, thread d4f235a3). Spec red first: an alt link made in Alts on one client reaches the other live,sync_two_peers_spec.lua(desk run 20261014-033843.809); green, with async_records_spec.luacase for the flag, 20261014-033929.751.- A batch naming one key twice pushes only the copy it kept; sending the overwritten one too could
leave a receiver on it at an equal
setAtwhen its hash is the greater (self-audit; spec red first, desk run 20261014-034218.754). - Tests:
env_ugr.peerLoginfiresGUILD_ROSTER_UPDATESTABLE_THRESHOLD + 1times, as the single-client specs do. Once was never enough for LibGuildRoster to go ready, so a two-client login never ran the roster comparison and the member store stayed empty (traced for Peer Review on thread d4f235a3:initialized=falsein desk run 20261014-034313.237). A new spec asserts both members are recorded, and the Alts live-push spec no longer adds them by hand. Sync:OnDataReceivedrefuses a live push from anyone the lane does not take as a peer before storing anything, and completes no P2P item for it.- Specs red first (desk run 20261014-032519.265), green 20261014-032603.276: a new block in
sync_records_spec.lua, SY-23 insync_two_peers_spec.lua(an edit reaches the other client live, hash unchanged).sync_bans_spec.lua's "pushes nothing of another kind yet" asserted the old refusal this work removes, so it now asserts the general host carries it and the ban host never does. Sync:SaveRecordsetsbyto the player (GetNormalizedPlayer) when the record names no author, before the stamp, so the author is inside the hash (sync.md SY-05); a record that names its own, as Alts does, keeps it. Spec red first (desk run 20261014-034709.600). Two older specs computed an expected hash withoutby, so each now includes it:sync_spec.lua"SaveRecord passes a supplied setAt", and SY-25 insync_two_peers_spec.lua, whose copies now name one author, as two clients would otherwise stamp two differentbys. No player sees this yet: the onlySaveRecordcaller is Bans, whose records go to the ban lane, and Alts names its ownby(Peer Review P3, thread f596b0c0). Ban records saved throughSaveRecordnow gainbytoo; it is only hashed.- The general rank gate (sync.md SY-05, SY-06):
Sync:Accepts(sender, kind)takes a general record, live or by catch-up, only from a sender at or above its kind's threshold (customNoteRankfor a custom note,generalRankotherwise,Sync.ClassOf), judged on the sender's rank and never onby, the shape the ban lane uses. Everyone qualifies at the lowest rank; nobody while the ranks load.SaveRecordreturnsniland the reason for a general record the player may not author. Before this a live push was taken from any guild member with sync on. Six specs insync_records_spec.lua, red first (desk run 20261014-035001.192), green 20261014-035119.466. The general gate inSaveRecordhas no production caller yet (seebyabove); the player-visible gate is the receive side. Sync:SaveRecordsapplies the same rule per entry: a general record the player's rank may not author is skipped, and the rest of the batch is stored. It also sets an absentby, asSaveRecorddoes. Before this an officer below a raisedgeneralRankcould import alt groups that every other client refuses from them, so they lived on the importer's client only (Peer Review P2, thread f596b0c0). Spec red first insync_records_spec.lua(desk run 20261014-035629.114), green 20261014-035719.365. The SY-07 spec insync_spec.lua("SaveRecords stores a whole import") computed its expected hash withoutby, so the whole suite went red on it (desk run 20261014-040518.176) onceSaveRecordsset one; it now includesbyand asserts it is the player, for the same reason as the two specs above.- Known cost, pending on DeltaSync (UGR request be665d97): in a guild that raises a threshold, a member
below it can still be picked as a catch-up provider for an officer's record, and the refused reply
waits out DeltaSync's 180 s delivery timeout before a new catch-up starts. At the default lowest rank
nothing changes. A three-client spec in
sync_two_peers_spec.luameasures it (red, desk run 20261014-035905.294) and is pending until DeltaSync ships a refuse-and-advance call.
Bans learn a GUID, and a banned stranger's join is warned about at once (BN-17)
Bans:OnBannedPresentandBans:CheckPresencewrite a GUID the member record has gained since the ban into a newer version of a ban that has none (ban-list.md, Edge cases), so a later rename still finds it. No log entry; never for a ban that already has a GUID, or when the player may not author.- A stranger banned before they ever joined was warned about only at the next login, though BAN-05 is
"warn loudly when a banned player is in the guild". Roster tracking now marks such a join
bannedand callsOnBannedPresent(name, true), which logsbannedPresentand says it in chat at once. - Specs red first (desk run 20261014-032632.043), green 20261014-033011.184.
Fixed: a live joiner broke the next login's roster comparison
- A joiner LibGuildRoster learns from the chat line has no rank, class or GUID until its roster row
arrives, an invented level 1 and empty notes (LibGuildRoster-1.0.lua:2759-2786), and the library fills
them later with no callback.
Roster:Refreshcopied those onto the new member's record, so the next login's comparison raised "attempt to compare number with nil" in the rank check (Roster.lua,present) and the whole comparison stopped; with that fixed, it would have logged a false level-up and a note added.Refreshnow stores rank, level and notes only once the entry has a rank, and the comparison reads a saved rank of nil as unknown, not a move. Found while writing BN-17; spec red first (desk run 20261014-032916.809). - A chat-line joiner promoted or demoted later in the same session raised "attempt to compare nil with
number" in
Roster:OnRankChanged: their saved rank stays nil all session, and the library fires its rank callback once the row has filled in (Peer Review N1, thread d4f235a3).OnRankChangednow takes the old rank from the library's callback when the saved record has none, and logs the promotion or demotion; when neither side knows it, it stores the new rank and logs nothing. Two specs red first (desk run 20261014-033638.677), green 20261014-033655.461.
Bans tab and the add/edit dialog (BN-13 to BN-16)
GUI/Tabs/Bans.lua(ns.Tabs.Bans) andGUI/BanDialog.lua(ns.BanDialog), in all five TOCs afterGUI/Tabs/Alts.lua. The tab: a strip (Refresh, Add ban, and "Remove all banned members" greyed as not built, BAN-07), a search over name, full name and reason, and the active bans inBans:List()'s order with Name, Rank (the member's rank, else "Former member" or "Never a member"), Banned, By, Flags ("In the guild", "Not identified") and Reason. Right-click a row to edit the ban or lift it after a confirmation.UGR_BANS_CHANGEDrepaints it while it shows.- The tab exists only at or above the ban rank:
MainWindowbuilds its tab list fromBans:CanSee(), andSync:CheckBanHostsendsUGR_BAN_ACCESS_CHANGED(canSee)whenever the player's right to author bans changes, sync running or not; the open window rebuilds its tabs and moves off the Bans tab if it was on it (MainWindow:RefreshTabs)./ugr bansopens it, or says "The ban list is for officers.". - The dialog is a
W:CreateFormDialog: a searchable name dropdown over current and former members with the typed text offered first for a stranger, Realm (no row on WoW Forever), Class (the client'sCLASS_SORT_ORDERandLOCALIZED_CLASS_NAMES_MALE, both new.luarc.jsonglobals, defined in every flavour tree) and Reason. Picking a member fills class and realm; a refusal stays under the rows.ns.Constants.BAN_DIALOG_NAMEis its global name.UI.Listtakes an options table for the row click. - Realm is locked whenever the chosen name already carries one, and in an edit:
Bans:Banappends a realm only to a bare name andEdittakes none, so a realm typed there was dropped without a word (Peer Review F3, thread 4151079e; two specs red first, desk run 20261014-030950.584). Unlocking empties the field: after picking a member, a bare stranger's name would otherwise have been banned on that member's realm (found in my self-audit; spec red first, desk run 20261014-031741.523). - A language switch rebuilds the dialog, open or not, under the next generation's global name: the form
dialog has no row-label setter, so its labels were fixed until a
/reload(F4; spec red first, same run).BanDialog:RefreshLocaleruns fromMainWindow:RefreshLocale; LibAceGUIWidgets has been asked for a label setter. - A refused Unban shows its reason in a one-button dialog over the tab instead of chat, since the reason is in the window's language (F5; spec red first, same run).
ns.Compat.RealmOf, nil-safe, replaces the two copies ofrealmOfinModules/Bans.luaand the dialog; the module's copy raised on a non-string (F6; spec red first, same run).- The dialog's Realm is a searchable dropdown like Name (BAN-03): the typed text first when it is no
suggested realm, then the matching
Bans:RealmSuggestions(), the realms in the saved member and former-member names. It was a plain field. The realm specs read it withGetValuenow, since a dropdown has noGetText; one new spec covers the suggestions (red first, desk run 20261014-032343.922; green 20261014-032408.193). - The Settings tab's status line no longer says the tab is a placeholder.
slash_spec,window_specandtoc_specwere updated for the new tab and files.
Sync on and off (SY-15)
- Sync off now stops the ban host as well as the general one:
Sync:Enabled()is read byStartBanHostandCheckBanHost. A ban saved while sync is off is still stamped, numbered and kept (Sync:BanSaveLanebuilds the host without starting it), and is shared once sync is back on (Peer Review b10eb582, F5). - Turning sync back on announces what the player holds at once, instead of at the next login
(
Settings:Set; spec red first, desk run 20261014-025639.736). SY-15's older case now counts two broadcasts, the automatic one plus its ownAnnounce, for that reason.
Ban list (BN-01 to BN-12)
Modules/Bans.lua(ns.Bans), in all five TOCs afterModules/Alts.lua:Ban,Edit,Unban,Get,IsBanned,List,IdFor,CanSee,OnBannedPresent,CheckPresence,Rekey(andOnMemberRenamed, the nameMembers:Rekeycalls),RealmSuggestionsand thebanapplier. One record per player, saved throughSync:SaveRecordon the ban host only; an unban is a newer version and nothing is deleted. Banning a player already banned edits that ban; a re-ban after an unban is a new ban under the same record.- Every edit needs
Sync:CanAuthor("ban")and returnstrueorfalseplus the message to show, as Alts does (ban-list.md said a reason key). Below the ban rank nothing is shown, saved or warned about, and a table kept on disk from before a demotion is hidden, never deleted. IdForis DeltaSync's frozenComputeHashV2of the lower-cased canonical name, twice with two labels, as 16 hex digits (sync.md said 12 characters of one hash). It is not a secret; see the next section.- Reasons are limited to 150 characters, counted with
strlenutf8, so a reason in a two-byte script is not cut at 75. Every client has it: Blizzard's own AutoComplete calls it in the classic_era, classic_anniversary, classic (Mists), live and forever trees.strlenutf8is a new.luarc.jsonglobal. Roster:CompareAtLogincallsBans:CheckPresenceonce the members are current: onebannedPresententry per banned current member, not repeated in the session, and none for a player a rejoin already reported.- A received NEW BAN ACT another officer authored logs
banReceived: the version it replaced was absent, unbanned, or had anotherbannedAt. My first build logged every received active version, so another officer's edit or rename read as a new shared ban, and a newly promoted officer's catch-up of 150 bans printed 150 chat lines (Peer Review B1, thread 37cb4ede). A live push says its own line; the first catch-up ban of the session says one line pointing at the Bans tab and later ones say nothing. There is no end-of-session hook in DeltaSync's numbered P2P and directive b15a1e31 rules out a timer, so the line cannot carry a count. Sync now hands every applier a third argument,{ previous, received, live }, captured before the record is stored;EventLog:Say(category, text)prints one line through Announce's chat-window rule (now one localchatFrame). BN-12's first case sent an edit and expectedbanReceived; it now sends a new ban, for that reason. banReceivedwas gated and credited onby, which is whoever saved the version last, not who banned: a ban edited by a second officer was credited to them on catch-up, and the player's own ban caught up after a wiped SavedVariables read as shared by someone else. It now gates on and namesbannedBy(Peer Review B7, thread 314c672a); two B7 specs were written red first (desk run 20261014-023519.479). A received version naming no banning officer is stored but logs nothing, where it had logged an entry with nofrom(found in my self-audit; spec red first, desk run 20261014-023839.897).- The ban lane accepts any version a peer at the ban rank sends, and the log credits
bannedBy(ban-list.md C2). It first also refused a version whosebythe roster held below the ban rank (Peer Review C2, thread c93f2110). That check judged the author's rank NOW: a ban by an officer since demoted, never edited, was refused by every officer promoted later, while one by an officer who had left the guild was taken; andbytravels in the record unchecked against the sender, so it stopped no forgery the sender check had not. Removed (Peer Review ruling 82af8aa5, thread b367ea9d):sync_bans_spec.lua's low-author case was inverted and run red first, then the check deleted fromBanLane:OnDataReceived; a new case pins the unedited ban by a demoted officer. sync.md records that the general lane's plannedAcceptmust not repeat the author check. CheckPresenceandRekeyrefused only because the ranks were loading were dropped, so the login warning could be lost for the session and a rename left the ban under the old name (B2, B3). Both now queue and run once whenGUILD_RANKS_UPDATEorGUILD_ROSTER_UPDATElets the player author bans (Bans:Enable, called fromOnEnablebefore the roster, andBans:RetryWaiting, which checks the ban host first because a held-back rename saves through it).Rekeyreads the GUID from the member record under the NEW name:Members:Rekeymoves it before it calls Bans, so the GUID path never fired and a ban saved under another spelling was missed (B3).Banrefuses the player's own name (B5);Editno longer takes a realm, which is part of the name (B6).- Six new locale strings: "Enter a name.", "%s is not banned.", "Reasons are limited to %d characters.", "Bans cannot be saved right now. Try again in a moment.", "You cannot ban yourself." and "Other officers' bans have arrived. Open the Bans tab to see them.".
Tests/bans_spec.lua, written red first (all 30 cases failed on the missing module, desk run 20261014-021357.368), including a two-client case that carries a reason with~,|and?across the real wire. One assertion was corrected after: a name on another realm keeps its realm in a message, becausens.Compat.ShortNamedrops only the player's own realm.Tests/toc_spec.lualists the new file. The B1, B2, B3, B5 and B6 cases were written red first (9 failed, desk run 20261014-022631.209).
Sync: the numbers handshake gate (Peer Review U1/U2, thread b10eb582)
- DeltaSync answers a
numbers-requestfrom anyone and adopts anumbers-replyfrom anyone. On the ban host the first handed the whole key list to any whisperer, and since a ban's id is an unkeyed hash of the name, hashing the roster read the ban list off it; on either host one reply ofn = 10000ended numbering for good. A stopped ban host still answered. - STOPGAP until DeltaSync gates it (DeltaSync inbox c9feae7f):
build()replacesHandleRequestandHandleReplyon each host's own numbers instance, so both answer and adopt only the lane's valid peers, and a stopped ban host nobody. - The design docs no longer call ban keys opaque: the id stops duplicate bans and hides nothing, and keeping bans from lower ranks rests on the gate (sync.md gap 4, ban-list.md).
- The ban lane inherits only Sync's functions, plus
APPLIERSandKEYSby name, so a per-host field added to Sync later cannot read through the lane as the ban host's own (Peer Review U3). - Four handshake specs and one inheritance spec in
Tests/sync_bans_spec.lua, run red against the code with the gate and the old inheritance switched off (desk run 20261014-021258.695), then green.
Sync: the ban host (SY-17 to SY-20)
- Bans travel on a second DeltaSync host, namespace
"UGRBans"(ns.Constants.BAN_SYNC_NAMESPACE), that only a player at or above the ban rank runs (operator decision 4). Below it the host is never built, so no ban prefix is registered and no ban is asked for, received or answered. ns.Sync.banLaneis the ban half of sync: Sync's own catch-up methods over the ban dataset (global.guilds[key].bans,"ban:<id>"-> record) and the ban host, overriding only the peer rule (a guild member at or above the ban rank), the dataset, and the running check. Both hosts are built by one function, so no catch-up rule is written twice. Its number table isglobal.guilds[key].banSync; both tables are new AceDB defaults, so no migration.Sync:StartBanHost,StopBanHost,BanHostRunningandCheckBanHost. DeltaSync cannot take a host down and the client cannot unregister a prefix, so a stopped host stays registered and every hook refuses.CheckBanHostruns at enable, on a guild change,GUILD_RANKS_UPDATE,PLAYER_GUILD_UPDATE,Roster:OnRankChangedand everySettings:Set.Sync:SaveRecordandSaveRecordsroute abanrecord to the ban dataset and number it on the ban host (one mint per host touched in a batch), and store no ban while the ban host is not running.SaveRecordpushes a ban at once:Sync:Pushwhispers it to each online member the ban lane takes as a peer, never the player and never GUILD. The general live push (SY-05) is still not built, soPushrefuses any other kind.Sync:OnDataReceivedrefuses a ban, by kind or byban:key, on the general lane and anything else on the ban lane; the ban lane also refuses a sender below the ban rank and an author the roster holds below it. A live push completes no P2P item.Sync.KEYS.banfiles a ban under"ban:<id>".Sync:RankOf(name)reads LibGuildRoster'srankIndex.Tests/sync_bans_spec.lua, written red first (all 17 cases failed on the missing API, desk run 20261014-015511.943). Two of its own assertions were corrected before the code passed: DeltaSync's prefixes are lower-cased (README.md:176) andNumberOfreturns the four-digit string.Tests/sync_spec.lua's announce counter now counts the general host only: it wrapped everyNewHost, and the ban host's own announce read as a second general broadcast.Tests/alts_spec.lua: the refused-feed spec also asserts, on the second shape change, one more stale offer and noRemoveAltGroup, and was run red against a mutation that records a refused feed (Peer Review, thread fa27a466).
The earliest 0.2.0 sections (the Alts tab and store, the Log tab, the event log, roster tracking, the
settings page, packaging and the test environment) and the whole of 0.1.0 are in
CHANGELOG_ARCHIVE.md.
All Relations
- All Relations
- Embedded Library
- Optional Dependency
- Required Dependency
- Tool
- Incompatible
- Include