ClassicCalendar-v1.6.7
What's new
Classic Calendar
[v1.6.7] (2026-09-12)
Login freeze: the hidden calendar redrew its whole month grid every time ANY addon moved the month
- Fixed a 7-8 second single-frame stall at login, attributed by the client's profiler to AllTheThings, that was almost entirely this addon's redraw. Measured in game with per-call timers (peer-review 2026-09-12): 69 calls to
CalendarFrame_Updatetotalling 16,146 ms, every one inside an ATTC_Calendar.SetMonth/SetAbsMonthcall (16,149 ms of month changes against 64 ms of calendar reads), with the calendar window never open. Two facts established: the client dispatchesCALENDAR_UPDATE_EVENT_LISTsynchronously, insideSetMonth, to every registered frame -- so a handler's cost is paid by the caller and blamed on the caller -- and oneCalendarFrame_Updatecost 230-560 ms on Era. - Harness pin moved
ca46bbf->55b0c88. Adopts42d606b's three deliveries against this addon's 2026-09-08 contracts: the inertOnSizeChangedmixin bodies and the yieldingSetElementExtentwrapper are deleted fromTests/env_cc.lua, the deadGetServerTimeLocalguard with them,framexml_spec'sBLIZZARD_FUNCTIONSexcuse list is empty (the declined-then-reversedCreateScrollBoxListLinearViewis real now), andwow.withoutApidrives the oneIsOfficerbranch that had been recorded as unspeccable -- the harness measured that the removal always took and areset()was undoing it. 577 passing on the new pin. CalendarFrame_OnEventnow returns early onCALENDAR_UPDATE_EVENT_LISTwhileCalendarFrameis hidden. Blizzard's handler has no gate and needs none --Blizzard_Calendaris LoadOnDemand, so it is not registered until the player first opens the calendar. This addon is always loaded and registers inOnLoad, so it paid a full 42-day redraw for every month change any addon made, from login onward, into a frame nobody could see. Nothing is lost:CalendarFrame_OnShowresets to today and redraws unconditionally. The other two handlers for this event (CalendarTodayView.lua, the event picker) already checkedIsShown(). In-place rather than a Patches.luaSetScriptwrapper: the function already carries ourPLAYER_LOGINbranch, and a wrapper drops silently if anything re-setsOnEventlater. Location:ClassicCalendar.lua.- Each holiday's day-window is now computed once when the schedule is built, not per holiday per day per redraw.
stubbedGetNumDayEvents,stubbedGetDayEventandHolidayCoversTimeeach derivedtime(SetMinTime(holiday.startDate))-- a table copy plus anos.time-- on every call, for every holiday, for every one of the 42 day buttons: up to 500 x 42 x 3 per redraw. That arithmetic, not the grid work, was most of the 230-560 ms.GetClassicHolidaysnow stampsminStartTime/maxEndTimeon each cached entry after the sort; the two hot loops inPatches.luaandHolidayCoversTimeinHolidayData.luaread the fields. The fields live on the cached entry, so the day-rollover and/calrefreshrebuilds regenerate them with it, and everystartDate/endDateassignment in the addon (29 sites) is inside the schedule build, upstream of the precompute -- nothing mutates a date after caching.HolidayCoversTimekeeps a derive-on-absence fallback because specs andnewGetHolidayInfomay pass raw holiday tables; the hot loops deliberately do not, so an entry that somehow lacked the field would fail loudly rather than silently go slow. ThenumSequenceDaysarithmetic atPatches.lua~529 still derivestime(SetMinTime(endDate))itself: substitutingmaxEndTimethere is off by one across a DST boundary, and that line only runs for a matched holiday. Very likely also this addon's own 850 ms line in the client's login profiler, since Questie'sOpenCalendar()provokes the same event at login. - With the calendar OPEN, another addon walking the month no longer redraws every step, and no longer leaves the grid on whatever month the walk stopped. The client's viewed month is process-global: ATT's scan (18 months back, 31 x
SetMonth(1), back to today) moved it for us too.Patches.luanow records the month this addon asked for (state.viewed, moved only bystubbedSetAbsMonth/stubbedSetMonth) andClassicCalendar_ClientMonthIsForeign()comparesC_Calendar.GetMonthInfo(0)against it. A shown frame receiving the event on a foreign month skips the redraw and schedulesClassicCalendar_RestoreViewedMonthSoon()-- oneC_Timer.After(0)per burst -- which, if the client is still elsewhere once the walk is over, callsC_Calendar.SetAbsMonthback to our month. Deferred deliberately: restoring inside the handler would move the month back under the other addon mid-scan, corrupting its results and firing another event into every listener from inside their handler. Goes throughC_Calendardirectly rather thanstubbedSetAbsMonth, so the selected-event tracking survives -- our shadow state was right, only the client drifted. A walk that passes through our month redraws once, correctly; a 33-step scan now costs 2 redraws instead of 33. The restore callsCalendarFrame_Updatedirectly as well as relying on the event, so a flavour that delivered the event late could never leave a stale grid -- one spare redraw per foreign burst, accepted and pinned by a spec. CalendarFrame_OpenToGuildEventIndexnow goes throughstubbedSetAbsMonth, notC_Calendar.SetAbsMonth. Its own comment says it replicatesOnShow, which uses the stubbed form. The raw call left the shadow on the previously viewed month, so the followingstubbedSetMonth(offset)stepped from the wrong base, and with the foreign-month check it would have read as another addon's move and been undone next frame.state.presentDatewas the wrong field to detect a foreign move with.UpdateCalendarStatere-points it at whichever day the player clicks, including a greyed-out day of the adjacent month, so it does not name the viewed month.state.viewedis kept separately and moves only when this addon moves the month.Tests/env_cc.luanow models the client's viewed month as shared state, withSetMonth/SetAbsMonthdispatchingCALENDAR_UPDATE_EVENT_LISTsynchronously to every registered frame, as measured.GetMonthInfofollows the harness clock lazily until something moves the month -- a snapshot at reset brokedarkmoon_spec, which parkswow.epochaftercc.reset(). ACalendarFrameleft shown at the end of an example keeps redrawing, for real, in every later spec file that moves the month (the registry keeps referenced frames across resets), sohiddenredraw_spechides it inafter_each.- Added
Tests/hiddenredraw_spec.lua, 11 examples, driving the real XML-builtCalendarFramethrough its boundOnEventscript: hidden -> zero redraws; shown -> one per event;Show()rebuilds; a 33-step foreign scan costs 2 redraws; the player's month is restored whether the scan ends elsewhere or on today; one timer per burst; the selected-event record survives the restore; the player's own paging stays synchronous; the guild-event jump is never read as foreign. Plus one inholidaydata_specprovingHolidayCoversTimeconsults the precomputed window (a cached window that disagrees with its dates wins). - Not measured in game yet. The 230-560 ms and 8 s figures are the investigating session's probe; the effect of these changes is predicted from the mechanism, not yet observed. ATT's own half (a scan re-run every realm-hour because the native Era calendar never has holiday events) is being fixed upstream, but per the operator this addon must be robust to a stock ATT regardless.
Officer check now delegates to LibGuildRoster
WorldBuffDB:IsOfficer()delegates toLibGuildRoster:IsOfficer()(shipped in the library's MINOR 12 specifically because this predicate was being re-derived by this addon, by TOGBankClassic and by the library's own consumers). Same rule -- the granted officer-note permission, never the rank index -- now in one home. GuildRoster is already a hard dependency andWorldBuffDB.luaalready loaded the library, so this adds nothing. The comment atWorldBuffDB.lua:209had named this as the end state since v1.6.5; GuildRoster's re-delivered note about the bare-global call (fixed in v1.6.6) is what surfaced that it had never been done. Location:WorldBuffDB.lua.- The old body is kept as the degrade path for a GuildRoster older than MINOR 12 (no
IsOfficermethod) or absent, so a mismatched install answers false rather than raising -- theSetShown()call sites cannot survive an error. TheIsInGuild()guard stays in front of the delegation: the library guards too, but this file should say the permission is meaningless outside a guild itself. - Two specs added to
Tests/officergate_spec.lua. The delegation is proved by sentinel, not by agreement: the library'sIsOfficeris replaced with one that contradicts the permission and the addon must follow the library; itsnil("unknowable") answer must come back as a realfalse. The fallback is proved by deleting the method and steering the namespaced permission. The "called in exactly ONE shipped file" scan still holds -- the fallback is that one file.
Test suite: two peer-review findings on the specs themselves
- The source scans now read EVERY TOC through one shared discovery (peer-review finding 21).
massinvite_specandofficergate_speceach openedClassicCalendar.tocby name -- the hardcoded form finding 4 removed fromtocorder_spec, re-typed in the spec written to close finding 18 -- and were complete only becausetocorder_specasserts the manifests are identical, an invariant neither cited.Tests/env_cc.luanow ownsdiscoverTOCs(),shippedLuaFiles()(every TOC's.luaentries, each asserted to exist) andshippedSource(); all three specs use them, and the mass-invite scan asserts it scanned every listed file and at least seven, so "no offenders" over nothing cannot pass. - The slash-command documentation is asserted against the source (peer-review finding 22). New
Tests/slashcommands_spec.lua, 8 examples. Registrations are enumerated from the source with string literals kept, reading every quoted/wordon aSLASH_*line so the two multiple-assignment sites are counted -- the reviewer's own first pattern found 17 of 19. A floor of 19 guards the enumeration and a computedSLASH_key is refused, since no text scan could see one. The set must equalREADME.md's commands plus a named list of four developer diagnostics, in both directions; the CurseForge description must carry every player-facing command; andCLAUDE.md's own list -- the one a session consults instead of the source -- must carry all nineteen. It was missing seven (/wbamong them, which is how/wbreached no document) and is corrected. Prose facts are not enumerable this way. Suite: 576 passing.
[v1.6.6] (2026-09-08)
Invite list — the ON column no longer collides with LVL and Status
- Fixed the invite list's
ONcolumn being overlapped byLVLon its left andStatuson its right. Reported from a live guild event with 19 signups, and not reproducible on the developer's machine — which is the part that identifies the cause. It was read as a resolution/monitor problem and it is not one. TheStatuscell and the online dot are anchored to the row's right edge (ClassicCalendarTemplates.xml), whilenameDelta— the growth the Name and Rank columns absorb when the window is widened — was computed from the invite list's width inCalendarEventFrame_ApplyWidth. Those two widths differ by exactly the scrollbar, which appears only once there are enough signups to scroll. So on a scrolling list the left-hand columns kept growing into space the row no longer had, andLVLslid underneath the dot. The variable is the signup count, not the screen: a 500px list with a scrollbar gave the columns 206px of growth while the row was 480px wide, puttingLVL's right edge at 413 against a dot at 404 — nine pixels of overlap. Location:ClassicCalendar.lua. - The growth is now fitted once, against the width a row actually has. New
CalendarEventInviteList_FitDeltas(rowWidth, nameDelta, rankDelta)trims the two deltas soLVL's right edge always clears the online dot by a fixed gap. It is applied inApplyWidthand the fitted values are what get stored on the invite list, so every row and every column header downstream reads one already-correct answer and cannot disagree. Deliberately not re-fitted per row: a row is sized by the scroll box and is not guaranteed to have been sized when its initializer runs, so reading its width there would make a row's columns depend on how far through layout it happened to be — the same class of defect as the v1.6.5 "rows disagreeing with each other in one list" bug. - Clamping only the
LVLcolumn would have fixed the symptom and created a new one.Rankis42 + rankDeltawide starting at145 + nameDelta, so pinningLVLalone would simply have slidRankunderneath it instead. The deltas are the unit that has to be fitted. When the fit has to take space back it takes it from Name before Rank, because Rank's width was measured to fit the longest rank title in the guild byAutoFitWidthand shrinking it truncates "Guild Master", whereas Name already ellipsises. - No change at all when the list does not scroll. The gap constant is 6px, chosen because the row template's default width of 290 leaves exactly 7px between
LVL's right edge (207) and the dot's left edge (214) — anything larger would make the shipped default layout fail its own invariant and claw back width nobody asked it to. Every existing user who is not scrolling sees the layout they already have. - Column headers are now placed over the row rather than over the list. New
CalendarEventInviteList_RowInsetsmeasures how far a row's edges sit inside the invite list's, andCalendarEventInviteList_AnchorSortButtonsoffsets every header by it. TheStatusandONheaders previously anchored to the list's right edge with a hardcoded 5px fudge whose own comment admitted it only held "in the common no-scrollbar case" — so with a scrollbar showing, all three right-hand headers sat right of the cells they name.ON's offset is now derived rather than fudged: the dot's centre is exactly 70px in from the row's right edge (Status is 60 wide, the 12px dot sits 4px left of it). - One deferred re-anchor after each width pass. The headers are placed by measuring a real row, and on first open — before the scroll box has acquired any — there is nothing to measure; the scrollbar also appears as a result of the row count, i.e. after the width pass has run. A single
C_Timer.After(0)re-anchor makes the placement converge without the user having to drag the window. Headers only, so it cannot re-enter the width pass. - Added four specs to
Tests/invitelist_spec.lua, including one that reproduces the reported case in numbers and first asserts that the pre-fix arithmetic genuinely overlapped, so it cannot pass vacuously. The 290px floor is stated in the spec rather than left as a silent gap: below the row template's own width the default column set does not fit at all, and no clamping of the deltas can help, because they are already zero.
New: mass invite tells a player why their invite did nothing
- Mass-invite now whispers a player whose invite was refused because they are already in someone else's group. Feature request from crimsonmane (2026-09-04); the whisper text is his, verbatim: "Guild Calendar - Attempted Invite but you are already in a group." Location:
Patches.lua, with one call added to_CalendarFrame_InviteToRaidinClassicCalendar.lua. - It needs a system-message handler, not a return value.
InviteUnitreports nothing — the refusal comes back asynchronously from the server as aCHAT_MSG_SYSTEMline, and that line names the player but says nothing about which invite it answers. So the addon records who it just mass-invited and correlates, within a 30-second window. - Only players WE invited, and only recently. Any other addon's invite produces the same system line; without the pending check this would whisper strangers, which is worse than not having the feature.
- It checks both raid and party, not just party. In a raid,
UnitInPartydoes not answer for members outside your own subgroup, so a party-only test would whisper most of your own raid. The membership is re-checked when the refusal arrives rather than trusted from the invite loop, because the group can change in between. - The pattern is built from
ERR_ALREADY_IN_GROUP_Sat runtime, never hardcoded. That global is localized — twelve spellings ship inGlobalStrings, and some put the player name in a different position. Matching English text would leave the feature silently dead on every non-English client, and invisibly so to whoever wrote it. The escaping happens before the%sis reopened as a capture, so punctuation in a translation cannot act as a wildcard. - Names are correlated on both spellings. The roster gives
"Player-Realm"; the server's refusal names them without the realm. Keying only one would never match, and the feature would be dead for every connected-realm guild. - Added
Tests/massinvite_spec.lua, 9 examples, including the non-English client, the realm-suffix correlation, the raid-subgroup case, whispering nobody twice, and refusing to whisper a stranger. - Sends through
C_ChatInfo.SendChatMessageonly; theor _G.SendChatMessagefallback is gone. It was a fallback that could not fall back.Blizzard_DeprecatedChatInfo/Deprecated_ChatInfo.luain the Classic Era tree returns early unless theloadDeprecationFallbacksCVar is set, and when it does define the bare global its entire body is a call toC_ChatInfo.SendChatMessage— so the global cannot exist on a client where the namespaced form is missing, which is the only case the fallback was written for. Flagged as deprecated by the language server. - Modelled
UnitInPartyandUnitInRaidin the test env, which had neither. Both are used by shipped code (ClassicCalendar.lua:1094and:5024), so every "is this player already grouped with me" branch raised offline and could not be specced — which is how theor/andbug below shipped.UnitInRaidreturns a raid index or nil, as the client does, rather than a boolean.
Right-click "Invite to Raid" was offered to people already in your group
- Fixed the invite-list row menu offering "Invite to Raid" to players who are already in your party or raid. The guard read
(not UnitInParty(name) or not UnitInRaid(name)), which is false only when both hold — so it was true for essentially everyone and the option never hid. Nowand, which is what "in neither my party nor my raid" actually means. Location:ClassicCalendar.lua:5024. (Peer-review finding 17) - The same predicate is spelled correctly 3900 lines away, in the mass-invite counter at
:1094. One concept, two spellings, and the menu had the wrong one — the class peer-review finding 6 was about, still live in this file after that finding was closed. - Both halves of the check are needed, not just the party one: in a raid,
UnitInPartydoes not answer for members outside your own subgroup, so a party-only test would re-offer an invite to most of the raid.
Darkmoon Faire always claimed to be in Mulgore, in every month of the year
- Fixed the Darkmoon Faire's description naming the wrong zone. Reported 2026-09-07 for that same day, with a screenshot: the September faire showed its own correct dates (9/07 to 9/13) beside the text "The Darkmoon Faire is here, this time at the foot of Thunder Bluff" — which is the Mulgore description, on a month the faire is in Elwynn Forest. Location:
Patches.lua,newGetHolidayInfo. - The schedule data was innocent, and that mismatch is the tell. Correct dates with wrong text means the lookup, not the table.
newGetHolidayInforesolved a day's description by matching the event's display name against each holiday's name — and every Darkmoon entry is named exactly"Darkmoon Faire", because the location appears only in the description. All twelve monthly entries therefore matched, and with nobreakthe loop overwroteeventDescon each one and ended holding the last entry in the table: December, a Mulgore month. - So it was wrong for the whole year, not just September — every faire in every month described itself as Mulgore, and was correct in exactly the six months that happen to be Mulgore. Being right half the time is why it survived this long: any spot check in an even month agrees.
- The fix matches on the day as well as the name, using the same date-containment test
stubbedGetDayEventalready uses to decide the event belongs to that day, so it cannot select a different holiday than the one the event came from — plus abreak, since a second match would be that same ambiguity again. - Added
Tests/darkmoon_spec.lua, which asserts the pairing across all twelve months of 2026. A single-month spec would have had a 50% chance of passing against the defect, so the shape of the assertion is the point. It also carries an anti-vacuous guard requiring that both zones are produced somewhere in the year — the defect returned one zone for all twelve. Mutation-verified: restoring the name-only match turns both examples red, with January (an Elwynn month) returning the Thunder Bluff text and January and December returning byte-identical strings. - Two env gates had to be opened for the spec to see anything, and both default off for good reason: the harness starts with no CVars at all, so
calendarShowDarkmoonfiltered the faire out entirely, andCCConfigis nil until the addon's first-login guard runs. Without them every month returns no event and the file passes by finding nothing.
Stopped a permanent guild-roster refresh loop that cost every OTHER addon in the client
- Fixed the mass-invite frame answering
GUILD_ROSTER_UPDATEwith another roster request before checking whether it was visible. The request produces a response, the response fires the event, and the frame registers inOnLoadand never unregisters — so the loop ran from login to logout with the calendar closed and never opened. Now guarded bycanRequestRosterUpdate and self:IsShown(). Location:ClassicCalendar.lua. (Peer-review finding 16, raised from LibGuildRoster-1.0) - The cost was paid by other addons, not this one, which is why nothing here ever looked slow.
GUILD_ROSTER_UPDATEis a synchronous event that seven handlers answer in this client, one of which rebuilds a 978-member roster in ~27ms. LibGuildRoster measured bursts of 3 events every ~30 seconds while idle and 9 on a guildmate logging out. - This is inherited Blizzard code, verbatim, guard placement included —
Blizzard_Calendar.lua:4170-4177in the Classic Era tree is byte-identical. It bites here only because Blizzard's calendar isLoadOnDemandand does not exist until the player opens it, whereas this addon is always loaded: their guard of "the calendar is open" silently became "the calendar is installed". The fix shields users from an inherited client defect rather than repairing a bug of our own. - One of the finding's two cited sites was already guarded, and we say so rather than claiming both.
CalendarCreateEventFrame_OnEventwraps its entire body inif ( CalendarCreateEventFrame:IsShown() )as its first statement, so its re-request never ran while hidden. The two blocks are identical in isolation and differ only in what encloses them — which is why the enclosing function has to be read, not the excerpt. A third frame registers the event and never re-requests at all. - Kept the request rather than deleting it, against the reviewer's stated preference, on their own strongest evidence: Blizzard's
FriendsFramemakes the identical re-request inside the visibility guard. The mass-invite list is built from the roster, so refreshing it while that window is open is exactly what the request is for. - Specced against the harness's own call counter (
guild.rosterUpdates), so it is measured rather than inferred: a hidden frame moves it by 0, a shown frame by 1. The second example matters as much as the first — without it the fix would be indistinguishable from deleting the feature.
Guild event creation, restored — a reported bug, and the same root cause as the officer check
- Fixed being unable to create Guild events at all, while personal events still worked. Reported after v1.6.5: "Calendar was working great now I can no longer make Guild events. I can make personal not guild events. Asked several guild members if they can. They have the same issues." The asymmetry is the entire diagnosis.
GenerateDayContextMenuadds the personal Create Event button first (ClassicCalendar.lua:2704), then enters anif IsInGuild()block whose first statement callsWorldBuffDB:IsOfficer()(:2709), and only after that adds Create Guild Event (:2717). BecauseIsOfficercalled a global that exists on no client (see below), that line raised, the guild half of the menu was never built, and the personal half — already added — survived. It hit every member of every guild, which is exactly what the reporter found when they asked around. Fixed by the officer-check fix below; no change to the menu itself was needed. Location:WorldBuffDB.lua. - This is the same defect as peer-review finding 15, reaching players through a different door. The finding described the world-buff consequences (Clear All missing,
WipeGuildrefusing the Guild Master); nobody had connected it to guild-event creation, because nothing drove that menu. The two were diagnosed independently and turned out to be one line. - Added
Tests/daycontextmenu_spec.lua— the menu had NO offline coverage at all, which is why this shipped. Four examples: an officer is offered both buttons; a non-officer is too while nobody has restricted creation (the gate fails open by design); the restriction still hides it once an officer sets it; and a guildless player is offered no guild button. Mutation-verified: restoring the bare-global call turns three of the four red withattempt to call global 'CanViewOfficerNote' (a nil value), the exact error players hit. The fourth stays green correctly, since a guildless player never enters the guild block. - Each example pins its own precondition, after a first draft that passed while proving nothing.
cc.reset()re-seedsguild.canViewOfficerNoteto true, so tunables set before the build were silently undone and the "non-officer" example ran as an officer. The examples now assertIsOfficer(), the restriction setting andIsInGuild()are what the example's name claims before asserting anything about the menu. - Modelled
C_Calendar.GetMinDateandC_Calendar.GetMaxCreateDatein the test env. Three shipped call sites read them and not one was reachable offline: the unmodelled call returned nil,date.weekdayraised, and every guard built on "may an event be created on this day" was undriveable. The day context menu sits entirely behind that guard. This is the missing edge that let the bug ship with a green suite.
Guild officer check — the permission API that does not exist
- Fixed
WorldBuffDB:IsOfficer()calling a function that exists on no WoW client. It called the bare globalCanViewOfficerNote(). That global is not real:GlobalAPI.lualists onlyC_GuildInfo.CanViewOfficerNote, the Classic Era client source has zero call sites for the bare spelling, Blizzard's own guild UI uses the namespaced form on every flavour, and it is not a deprecation fallback either. Depending on the caller this either raised, or — wherever the error was swallowed — degraded to falsy, meaning nobody was ever an officer: the world-buff "Clear All" button never appeared,WipeGuildrefused the actual Guild Master, and the rank-confirm config would not open for anyone. That is the exact inverse of the v1.6.5 officer-permission fix, and much quieter, because nobody reports a button they never expected to see. Now feature-detectsC_GuildInfo.CanViewOfficerNoteand degrades tofalserather than raising, since call sites assign the result straight intoSetShown()during a UI update. Location:WorldBuffDB.lua. (Peer-review finding 15, raised from LibGuildRoster-1.0) - The offline suite was certifying the branch no client takes.
Tests/env_cc.luainstalled a bareCanViewOfficerNotestub, which was the only reason the officer specs were ever green. That stub is deleted — deleting it is the finding's verification step — and the specs now steer the harness'sC_GuildInfo.CanViewOfficerNote, which is namespaced only, deliberately, for exactly this reason.
Internal: WorldBuffDB.lua and GuildRankConfirm.lua are both at 100% line coverage
WorldBuffDB.lua, 89% to 100%. The remaining lines were the library-absent guild-key fallback and two debug diagnostics. The fallback's specs assert the thing its comment actually claims -- that the hand-built key is identical to the one LibGuildRoster builds, so a client that starts before the roster library is ready cannot write to one store and read from another. The diagnostics are asserted on the counts they report, and that a dedup pass which merged nothing stays silent (it runs on every roster-ready, so a chatty one would print on every login forever).GuildRankConfirm.lua, 40% to 100%, in a newTests/rankconfig_spec.lua. Most of this file had never been driven offline at all. What it now pins, in order of what it would cost to get wrong: the 12-hour to 24-hour conversion in the create-mode event key (noon and midnight both, since settings are filed under that key and an off-by-twelve files them where nothing looks); the whole create-mode round trip, where an officer ticks ranks on an unsaved event and they are committed on creation -- including that a copied event does not inherit them; that the rank list includes ranks nobody currently holds, and falls back to the roster when the guild-control API is permission-gated; and that the dropdown writes, clears and hides as it should.- Every debug diagnostic in both files is asserted on what it says, not merely that it runs -- a line that reports a different number from the one it acted on is worse than no line, because it is read by someone already confused.
Fixed: two lines of addon chatter printed to chat on every login
- The VersionCheck-1.0 integration announced itself unconditionally.
ClassicCalendar.lua:182,184printed either "VersionCheck-1.0 integration enabled (v...)" or "library not found or Enable method missing" on every login for every player, regardless of/caldebug. Both are now behindClassicCalendar.debug. - This is the class v1.3.1 fixed everywhere else -- "debug messages bleeding through without
/caldebug" -- and these two escaped it only because they were added afterwards. Found while checking what the version check actually does in order to document it.
Fixed: Hardcore realms were classed as neither Classic Era nor Season of Discovery
isClassicErawasnot hasActiveSeason, and Hardcore IS a season. Blizzard's ownBlizzard_GlueXMLBase/Vanilla/Constants.lua:166-170keysSEASON_NAMESbyEnum.SeasonID.HardcoreandEnum.SeasonID.FreshHardcore, soC_Seasons.HasActiveSeason()returns true on a Hardcore realm. Every Hardcore, Fresh Hardcore and Season of Mastery player therefore had both flavour flags false, with no branch owning them.isClassicErais now the exact complement ofisSoD, which is the only season whose schedule actually differs from Era's.- This reached nothing, and that was luck rather than safety. The global has no shipped reader today, so nothing rendered wrongly -- but the next site to gate on it would have taken the SoD path, or no path, for an entire realm type. Found by asking which client a bug reporter was on.
Tests/patches_spec.luapins Hardcore and Fresh Hardcore individually, plus the property that matters more than either: across every season id, exactly one of the two flags is true. Mutation-verified -- restoring the old expression turns three examples red, one of them naming a season that belongs to neither branch.
Fixed: the mass-invite window could close itself on an officer who had just opened it
- Two gates governed one feature and asked different questions. The Mass Invite button was enabled by our officer test, while the window itself stayed open only if the client's per-rank
CanEditGuildEventpermission said so. For an officer whose guild rank lacks that client permission, the button worked, the window opened, and then the next guild-roster update hid it again with nothing said about why. Both now go through one definition,_CalendarFrame_CanMassInvite. (Peer-review finding 18) - The surviving question is the right one. The officer test already answers "no" when the player is guildless, which is the case the original code was written for ("if we are no longer in a guild, we can't mass invite"), so routing both through it keeps that intent and drops the second spelling.
Tests/massinvite_spec.luaasserts the two entry points AGREE, not merely that each works alone -- the defect was that they answered different questions about the same action. A source scan alongside it fails if any shipped file reintroduces the old call, which is the half a behaviour test cannot cover: finding 18 exists precisely because an earlier fix was applied at its known sites and never swept. Mutation-verified: putting the old call back turns three examples red, one of them reproducing the live symptom.C_Calendar.CanSendInviteis now modelled in the test environment. It was falling through a permissive catch-all that returns nothing, so every gate reading it was dead-false offline and no test could reach the permitted branch.
The date-containment test is now one definition instead of three
- Added
HolidayCoversTime(holiday, eventTime)andHolidayIsEnabled(holiday)toHolidayData.lua, and routed every site through them. The "does this holiday cover this day" comparison was written out inline instubbedGetNumDayEventsandstubbedGetDayEvent, andnewGetHolidayInfodid not perform it at all -- which is exactly how the Darkmoon Faire came to name the wrong zone in every month. One concept, three spellings, one of them missing. (Peer-review finding 18) - The CVar gate is a separate function on purpose. "Does this holiday cover today" and "has the player switched this category on" are different questions; only the two counting/listing paths ask the second, because the description lookup describes an event one of them already produced. Keeping them apart is pinned by a spec.
- Two assumptions behind the loops were checked rather than inherited. The early
breakis sound only becauseGetClassicHolidaysreturns the schedule sorted ascending by start date, and that ordering agrees with the containment key -- now stated in a comment at both sites, since it was load-bearing and unwritten. AndSetMinTime/SetMaxTimeare handed the shared cached holiday tables by all three sites; both copy rather than mutate, so these read paths cannot corrupt the schedule. - Six examples added to
Tests/holidaydata_spec.lua, covering both end days, the day either side, a single-day holiday, the asymmetric 00:01/23:59 bounds, and nil tolerance (one caller derives its time fromC_Calendar.GetMonthInfo, which can answer nil). Mutation-verified: changing the end bound toSetMinTimeturns four examples red, one of them a pre-existingpatches_specexample driving a real call site -- so the shared definition is bound where it is used, not only in its own unit test.
Removed: a world-buff legacy importer that never ran on any shipped build
- Deleted the legacy import inside
WorldBuffDB:Migrate(). It folded the old per-character arrays (WorldBuffRendDataand its three siblings) into the account-wide store, and it could not execute from any starting state:Migrate's first statement isself:Init(),Initraises_dataFormatto the current format, and the importer's own gate then read>= WB_DATA_FORMATand returned. Setting the marker back to 1 by hand did not reach it either, becauseInitruns first and raises it again. Verified twice indocs/AUDIT.md(finding 2 and the reviewer's follow-up) and never acted on. Location:WorldBuffDB.lua. - Nothing changes at runtime, which is the point. About 50 lines described an import no player ever received, so the file read as though legacy data was being carried forward when it was not.
Migrateitself stays --WorldBuffSynccalls it onGUILD_ROSTER_UPDATEand in the login catch-up -- and now does what it always did:Init(), then return false. The one-time discard of the corrupt v1 store isInit's and is untouched. - Removed with it: the
LEGACY_GLOBALSmap and thelegacyDroppedToBool,charKeyandrealmSuffixhelpers, which had no other callers. The four legacy saved variables stay declared in both TOCs so an existing player's file keeps loading. - Specced rather than simply deleted. The two migration specs are kept with their assertions unchanged, because the guarantee a player depends on is the same either way: marking the store as v1 must not resurrect legacy data. The
wbintegrityguard spec was inverted and made non-vacuous -- it readguard[name] or next(guard) == nil, which passes on an empty table, so it would have gone on passing after the deletion while testing nothing. It now asserts the guard stays empty, and goes red if an importer is ever reintroduced without a spec. - Line coverage on
WorldBuffDB.luawent from 89.26% to 98.58% as a direct result; the dead importer was most of the shortfall.
Offline test suite and linting
- Adopted a month of harness changes (submodule pin
5ada02f→ca46bbf). The suite runs 482 examples, all passing. - Added
.luacheckrc, which this addon has never had. Without it every file reported dozens of "accessing undefined variable" warnings for WoW client globals —WorldBuffDB.luaalone produced 94 — which is the permanently non-empty report that nobody reads and in which a genuine warning has nowhere to stand out. The globals list is read from.luarc.jsonrather than copied into it, so the language server and luacheck cannot drift apart. The ported Blizzard sources have the purely cosmetic warning families switched off to keep them re-mergeable with upstream, but the1xxfamily stays on everywhere — that is the one that catches a call to an API this client does not have, which is what the last three peer-review findings were all about. - It immediately caught two real accidental globals in the ported code, both leaking into
_Gfrom inside a function:CalendarFrame_UpdateFilterButtonsassignedtestDaywhere the local declared at the top of the function istestDate(a one-letter spelling mismatch), andCalendarDayContextMenu_ReportSpamwas missing alocalonreportInfo. - Added local models for four client APIs the harness does not carry, with the flavour checked in the Classic Era source rather than assumed:
C_Calendar.EventGetTypesDisplayOrdered,C_Club.GetSubscribedClubs/GetClubInfo,Enum.ClubType, andScrollBoxListLinearView:SetElementExtent. All four are genuinely present on Classic Era — Blizzard's own Classic calendar calls three of them — so the addon's unguarded use of them is correct code and these were missing models, not defects.
[v1.6.5] (2026-08-07)
World Buff sync — names resolved against the roster
- Fixed a world-buff entry's Main Name being stored as typed, which made the record's identity differ per officer.
mainNameis folded into the entry's content hash, butWorldBuffs:SaveEntryresolved it against the guild roster only opportunistically and otherwise kept the raw text — deliberately, because a main is a grouping label rather than a required guild member. The consequence was that two officers recording the same fact authored two different versions of one record: typingGhostandghostfor the same non-member main produced different canons, so each officer's edit reverted the other's, indefinitely, with the row appearing to "flip back" for no reason. All main names now go throughWorldBuffDB:CanonicalMainName, which resolves through LibGuildRoster first — covering every main who is a guildmate, which is nearly all of them — and otherwise applies only the deterministic steps that are client-independent (trim, and collapse hyphen spacing the way the library'sNormalizeNamedoes before appending a realm). Location:WorldBuffDB.lua,WorldBuff.lua. - Known remaining gap, pinned by a spec rather than papered over: a main the roster has never heard of still diverges on capitalisation. There is no client-independent normalization available for such a name — appending our own realm differs per client (worse on a connected-realm cluster) and case-folding would corrupt the display value the officer chose.
CanonicalMainNameis deliberately the single call site, so a fuller name-normalization entry point in LibGuildRoster wires in at exactly one place;Tests/wbintegrity_spec.luacarries a spec that fails loudly, with instructions, the moment that stops being a gap. - The migration guard is keyed by the library's own name for the character.
charKeyhand-builtUnitName("player") .. "-" .. <separately derived realm>— a second normalization that only happened to agree with the library's — and now usesGR:GetNormalizedPlayer(), the same string the roster keys this character under.
World Buff sync — connected-realm store key
- Fixed the world-buff store being partitioned per realm on a connected-realm cluster.
WorldBuffDB:GetGuildKeybuilt"<realm>::<guildName>", deriving the realm itself. The store is account-wide and guild-keyed, so on a cluster — where a guild's members, and your own alts, sit on different realms — an Atiesh character and an OldBlanchy character in the same guild resolved two different buckets inside one SavedVariables file. Logging the second character showed an empty window until sync refilled it from a guildmate, and anything recorded while nobody else was online to re-send it was simply not there. The key is now"Faction-GuildName"— byte-identical to LibGuildRoster'sGetHomeGuildKey, whose documentation is explicit that consumers must build the same string — obtained from the library when it is up and constructed identically by hand when it is not, so the key cannot change mid-session. Location:WorldBuffDB.lua. - Scope, stated precisely: this did not stop two online guildmates syncing. A receiver files an incoming row under its own key and the key never crosses the wire, so live push and catch-up both delivered regardless of realm. The damage was local to one account's characters. An earlier reading of this as a guild-wide sync failure was wrong and is not what was fixed.
- Realm normalization is delegated to LibGuildRoster rather than re-derived. The old helper fell back to the non-normalized
GetRealmName()whenGetNormalizedRealmNamewas unavailable and squashed whitespace only, where the library also handles the punctuation a realm name can carry (Al'Akir,Ravenholdt - PvP). Two normalizations that only happened to agree; the library is the single source of truth for every realm suffix in play, because it is whatNormalizeNameappends to bare names and therefore what every roster key and canonical player key is built from. - Existing realm-scoped stores are adopted, not orphaned.
WorldBuffDB:AdoptRealmScopedStoresfolds any"<realm>::<guildName>"store for the current guild into the new key on the next roster update, merged with the same last-writer-wins rules the sync uses (so it produces the identical result on every client and is idempotent). Entries carry their canon across untouched — this is a re-filing, not a re-authoring. The source stores are deliberately left in place rather than deleted.
World Buff sync — canonical hashes
- Fixed the root cause of world-buff data corruption: a record's hash was re-derived by every client instead of being authored once. DeltaSync's canonical-hash contract is that a hash is the identity of a version of a record — minted once, by the client that saved it, stored on the record, and carried unchanged thereafter. ClassicCalendar violated it in three places at once.
WorldBuffDB:_EnsureHxre-stamped every row in the store from local content on every index build (i.e. every login), silently overwriting each authoring officer's canon — including canons that had arrived from peers — with the current client's own opinion.PutEntrydid the same on every receive, discarding the sender's statement about their own record. And_hwas never placed on the wire at all, so there was no shared identity to carry: each client independently derived a number for every record, and the identity of a record therefore depended on who last logged in rather than on who saved it. Two guildmates holding byte-identical data could disagree on its hash permanently and re-offer the same rows forever, with no error raised anywhere. Hash minting is now confined to the two SAVE paths (UpsertLocal,MarkDeleted), plus a conditional mint at each of the two store-entry boundaries (PutEntry, and the adoption loop) for a record arriving from a peer old enough not to send one — four mint sites, and zero on any read path. Location:WorldBuffDB.lua,WorldBuffSync.lua. - The author's hash now travels with the record.
entryToObjemitsh;objToEntrycarries it back into_h;PutEntrystores it verbatim. Backward-compatible in both directions — an older peer ignores the extra field, and a record arriving without one has its canon minted once at the boundary. - The entry hash no longer folds the map key. It could not:
ApplyEntriesdeliberately re-files an incoming row under the receiver's canonical key (so an older peer cannot re-fragment a cleaned store), which means a key-inclusive canon is wrong the instant it crosses the wire and nothing would ever converge. The hash is now the identity of the record's content; the item roll-up still binds key to canon (key .. ":" .. canon), so a re-key remains visible at the item level, which is the correct level for it. Upgrade note: leaf values change, so every client's item hashes change once and the guild performs a single reconciliation pass on first login. Data-preserving — the merge is content-based.
World Buff sync — one tie-break, shared by every path
- Fixed an exact-timestamp tie being resolved differently by different code paths, so two clients holding identical data could keep different rows and never converge. Three places resolve a same-key collision —
AdoptRealmScopedStores,WorldBuffSync:ApplyEntriesandWorldBuffDB:Dedupe— and last-writer-wins bylastModifiedcannot settle an exact tie, so each falls through to a content tie-break. Adoption compared_hwhile the other two compared the content checksum. Those orderings are uncorrelated:_hfolds inlastModifiedand encodes booleans as"1"/"0"under a different fold, so on a tie the client that adopted a legacy store kept one row and a client that received the same pair over the wire kept the other. Both then believe they are converged, their item hashes differ permanently, and the offer/reject exchange repeats for the rest of the session with nothing raised. All three paths now callentryContentChecksum, which is field-for-field and separator-for-separator identical toWorldBuffSync'sentryTiebreakover the same FNV-1a fold — verified rather than assumed, since the defect was precisely a belief that two functions matched. Location:WorldBuffDB.lua. (Peer-review finding 3) AdoptRealmScopedStoresmoved belowentryContentChecksum, and its position is load-bearing. The checksum is a file-local; with the function above it the name resolved to a nil global and would have thrown on the first tie rather than merely ordering wrongly. Noted because the ordering is invisible at the call site and easy to undo.- The other two paths were deliberately not aligned onto
_h. A comparator that includeslastModifiedis not a tie-break at all in the general case — it can only be consulted oncelastModifiedhas already been found equal. - Added
Tests/wbintegrity_spec.lua— breaks an exact-timestamp tie the SAME way on every path. Each of the three paths already had thorough specs, in isolation, and none of them asked whether the paths agree — the defect sat in the gap between three green features. The spec seeds_hin the order opposite to the content ordering, resolves one identical tie through adoption and through the merge, and asserts both keep the same row: agreement, not a named winner, since which row survives is the implementation's business. A second assertion pins the survivor to one of the two candidates so it cannot pass by both paths dropping the entry. All three paths are driven, includingDedupe— which meets the tie from the other direction, two source keys collapsing onto one canonical key — because "agrees by construction" is precisely what had been believed about adoption. Mutation-verified per path: flipping adoption's comparator alone, orDedupe's alone, turns the suite red on this spec.
Officer permissions — one definition, and it is now the granted permission
- ⚠️ Behaviour change: "officer" now means the GM granted you officer-note access, not that your rank index is 0-2. The addon asked "is this player an officer" in ten places across five files using two incompatible rules. Eight tested
rankIndex <= 2— a position in the rank list, which is meaningless across guilds since rank names and meanings are entirely per-guild — and two testedCanViewOfficerNote(), a permission a GM grants per rank and can grant to any rank. In the very common0 = GM, 1 = Officer, 2 = Altlayout, every Alt passed the rank checks: the world-buff "Clear All" button was shown to them andWipeGuildaccepted them, letting an alt wipe the entire guild's world-buff dataset and propagate the wipe epoch to every member — while failing the permission check, so the same character could not open the rank-confirm config. A player was an officer to one feature and not another. There is now oneWorldBuffDB:IsOfficer(), on the permission, and all ten sites route through it. The permission is the right rule for this: it is the only one of the two that reflects something a GM actually decided, and what it gates is destructive and guild-wide. Locations:WorldBuffDB.lua,WorldBuff.lua,WorldBuffSync.lua,ClassicCalendar_Options.lua,GuildRankConfirm.lua,ClassicCalendar.lua. (Peer-review finding 6) - What this changes for a guild in practice. A rank-0/1/2 character without officer-note access loses Clear All, the Officer settings tab and officer-gated event creation. A character at any lower rank with officer-note access gains them. Guilds where officer-note access already tracks rank 0-2 see no change at all. If someone loses access unexpectedly, the fix is for the GM to grant their rank officer-note viewing.
- The finding named five sites; there were ten. The review said explicitly that its enumeration was "a floor, not a total" — it scanned file-scope
local functiondeclarations, so inline copies did not surface. Five more turned up: a second inline copy inWorldBuff.lua1250 lines from theIsOfficer()it duplicated, two inClassicCalendar_Options.lua(one of which drove the "Can create events" status row, so the panel would have reported a different answer than event creation enforced), and two of our patches inside the portedClassicCalendar.lua— the/cal raidsgate and the day context menu. A tenth was found by the new spec rather than by reading:CalendarCreateEventMassInviteButton_Update, where we had replaced Blizzard'sCanEditGuildEvent()with a bareCanViewOfficerNote(). - Added
Tests/officergate_spec.lua— behaviour, plus a scan of the shipped source proving nothing re-derives the check. The behaviour specs pin that a low rank with the permission is an officer and a rank-2 without it is not, so neither can be satisfied by a gate that just answers one way. The source scan is the part that matters long-term: a behaviour spec cannot notice an eleventh site being added, only reading the source can. It takes its file list from the TOC (so a new file is scanned automatically), strips comments and string literals first (every comment explaining this fix namesrankIndex <= 2), and asserts both that no file compares a rank index numerically and thatCanViewOfficerNoteis called in exactly one file. That second assertion is what found the tenth site. Mutation-verified by re-deriving the rank test inWorldBuff.lua, which fails it by name. - The offline
CanViewOfficerNotestub was hardcoded totrue, so no spec could model a member without the permission. It is now steerable viacc.canViewOfficerNoteand deliberately independent ofguildRankIndex— deriving one from the other would have made the very distinction this fix is about untestable.
Duplication removed where the copies had already drifted
Fixed an event title of
nilraising instead of falling back to a placeholder, in the world-buff event path.SanitizeEventTitleexisted twice —CalendarHelper.lua:725and a local inWorldBuff.lua:1600— with a line-for-line identical body and two differences. One was legitimate: a world-buff event wants the placeholder"World Buff Drop"where a raid event wants"Raid Event". The other was not: theCalendarHelpercopy guardedtitle == niland theWorldBuffcopy did not, so an absent title returned a placeholder in one path and raisedattempt to index a nil valuein the other. One author handled it, the other did not, and nothing linked them. There is now one implementation taking the placeholder as an argument — the real difference kept, the accidental one gone. Locations:CalendarHelper.lua,WorldBuff.lua. (Peer-review finding 9)Merged the duplicated Classic Era season shims.
SafeC_SeasonsandSafeEnumSeasonIDwere byte-identical inHolidayData.luaandPatches.lua, down to therawgetlint-avoidance and the fallback tables. These are client-compatibility shims, which is the code least worth having two of: the day a client changes shape you fix whichever copy you happened to open, and the other keeps answering the old way with nothing to indicate it exists. Resolved once inHolidayData.lua(which loads first) and published on the addon's private table. (Peer-review finding 8)Deleted a dead, broken second
adjustMonthByOffset, and pinned the surviving one on the cases that distinguish them. Two file-scope locals shared the name —Patches.lua:197(live, absolute-month arithmetic, correct for any offset) andHolidayData.lua:230(no call site anywhere). The dead one added the offset and then patched the result withmonth > 12andmonth == 0special cases, which is correct only for exactly ±1: from December with+2it clamped to January and discarded the remainder, and from January with-2it leftmonth = -1, because== 0never matched and no branch ran. Not a live defect — but an incorrect implementation under a plausible name, sitting in the addon's date-maths file, is what the next person needing to shift a month would find before finding the right one. Deleted. (Peer-review finding 10)The surviving copy was under-specced on exactly those cases, which is why the deletion alone was not the whole fix:
stubbedSetMonthhad only ever been driven with+1and+3within a single year — the range where the correct and the broken implementations agree.Tests/patches_spec.luanow drives six multi-month offsets across year boundaries in both directions, including ±18 months. Mutation-verified by substituting the deleted implementation, which fails it. Observing this needed a getter:UpdateCalendarStatewas a global setter with no counterpart andstateis a file local, so the calendar's own date could only be inferred indirectly —GetCalendarPresentDate()is the symmetric read, returning a copy so a caller cannot move the calendar by accident.Fixed world-buff dates being taken from your computer's clock instead of the realm's, which dated synced entries and guild events a day wrong for players in a different timezone from their server. Every date this feature defaults, displays or stores is a date about the server — when a buff dropped, when a guild event happens — but the four date pickers defaulted from
date("%m/%d/%Y")andreceivedDatewas stamped the same way in four more places. For an Oceanic player on a US realm those are different calendar days for much of the day: at 09:00 local on 8 August it is 19:00 on 7 August server-side. The picker offered the 8th, the player accepted it, and the entry synced to the guild dated a day after every guildmate saw the drop — with the calendar event landing a day late and nothing raising an error. All of it now goes throughGameToday()/GameTodayText(), and a stored server timestamp is rendered in the server's day viaGameDateText()rather than the machine's. The field ismonthDay, notday—C_DateAndTime.GetCurrentCalendarTime()returns the former, and reading the latter yields nil and silently produces a garbage date, so a spec pins it. Location:WorldBuff.lua. (Peer-review finding 11)The earlier fix in this release was on the rarest path and left a worse state behind. It corrected only the fallback used when
C_Calendar.GetMonthInfo()returns nil, so the reference month became game time while the operand stayed machine time — one subtraction across two clocks, disagreeing by construction rather than by accident. The four pickers run every time the dialog opens and their default is what a player accepts unless they deliberately change it.ClampSelectedDateToWindowstill reads the OS year and is deliberately left alone: it builds a ±1-year bound, which no timezone offset can move.A fifth picker default and two display sites were missed on the first pass, and the timezone helper itself was wrong. The fifth spelled its call
date("*t")rather thandate("%m")/date("%d")/date("%Y"), so a pattern search could not see it — it is the edit dialog's default when an entry has no date, on the same store-and-sync path as the other four. More seriously,GameDateText's offset was computed asGetServerTime() - time(), which is always ~0: both are Unix epoch seconds and epoch carries no timezone, so their difference is clock skew rather than a UTC offset, and the function reduced to exactly the machine-local formatting it was written to replace. (ThatGetServerTimeis a true epoch count is settled by the client shipping a separateGetServerTimeLocal, which would be redundant otherwise, and by Blizzard's ownexpirationTime - GetServerTime()duration arithmetic.) The offset now comes from the server's wall clock viaC_DateAndTime.GetCurrentCalendarTime(), and the "Last edited" tooltip and created-time suffix route through the same helper so a tooltip cannot show a different day from the row it describes. (Peer-review findings 12 and 13)Every arm of the stored-date repair is now specced, with the clocks deliberately diverged. The repair that rewrites a missing, malformed or out-of-range
receivedDatestamps "today", so all of it had to move to server time — and only two of its five branches had any coverage. Four specs added, one per remaining arm, plus a guard asserting a well-formed date is left alone: without that, a repair path that simply overwrote every date would satisfy all the others while destroying real data. Mutation-verified, and the shape of the result is the point — reverting the helper fails the three repair specs and leaves the guard passing, which says each spec measures its own branch rather than all of them measuring one thing.The suite could not have caught this and now can. The harness derives both
date()andC_DateAndTimefrom one epoch, so moving the clock moves both together and the machine and the realm always agree — the defect was unmodellable. The new specs overrideGetCurrentCalendarTimedirectly to drive the two apart, which is the only way to make them distinguishable. Mutation-verified.Fixed the world-buff guild-event path falling back to the machine clock where its twin used game time.
WorldBuffs:CreateGuildEventWithDateandCalendarHelper:CreateGuildEventWithDatecompute the same month offset from the calendar's displayed month, and both need a fallback for whenC_Calendar.GetMonthInfo()returns nil. One usedC_DateAndTime.GetCurrentCalendarTime(); the other useddate("%m")/date("%Y"), the local OS date. On a player whose machine sits in a different timezone from the realm, the two paths could land a month apart across a month or new-year boundary. The calendar is a server-side artefact and every other date in this addon is game time, so both now use it. Found by running the harness's newdupscan.luaover the addon rather than by a bug report. Location:WorldBuff.lua.Added a spec asserting the invite-status table we SHIP matches the client's.
Patches.luaassignsCALENDAR_INVITESTATUS_INFOas a global, overwriting the client's — so it, not the test environment's copy, is what every player gets. The spec added for finding 5 validated the test model; this one readsPatches.luaas text and compares it toBlizzard_FrameXMLBase/Shared/Constants.lua, so the artefact players actually receive is the one under test. The shipped table was already correct; it was simply unchecked, which is a different thing from being right. Mutation-verified.
Bug Fixes
Announcing a world buff drop that is already on the calendar no longer creates a second event. Two officers both seeing Rend go down and both hitting announce is the raid-night case: the world-buff path created a guild event unconditionally, while its
CalendarHelpertwin had done a duplicate check since it was written. The check is now shared. Matching is on title plus day, and world-buff titles are"<Player> Dropping <Item>", so a different player or a different buff never collides. Location:WorldBuff.lua,CalendarHelper.lua.The check needed fixing before it could be reused, and that is the interesting half.
EventAlreadyExistscompared start times with(hour or 0) * 60, which silently turns "no time given" into midnight. The world-buff path never callsEventSetTimeand so has no time at all — meaning the check, reused as-is, would only ever have matched events between 00:00 and 00:05. It would have been dead precisely where it was being added. "No time given" is now its own case, answering the question that path is actually asking: is this drop already on the calendar that day. Mutation-verified — removing the branch fails the positive spec while leaving both negative guards passing, which is what shows each is measuring its own thing.The event title is now derived once. It was built inside the create form's timer callback, but the duplicate check has to run before the event is created — so a second derivation would have meant the check guarding one string while a different one got created, which reads exactly like the check not working.
WorldBuffEventTitle()is the single definition and the callback uses it;SanitizeEventTitlemoved to file scope to serve both.Fixed the invite list's Class, Rank and LVL columns lining up for some rows and not others in the same event window. When the event window is widened, the Name and Rank columns grow and Class/Rank/Level shift right to match. That shift was applied only by the resize pass, which iterates
ScrollBox:GetFrames()— the row frames that exist at that instant. The scroll box acquires rows lazily and recycles them as you scroll, so every row acquired afterwards kept the XML defaults (Class 99 / Rank 145 / Level 189) while the headers and its neighbours used the widened offsets. A row's column position therefore depended on when its frame happened to be created, which is why the misalignment looked arbitrary rather than systematic. The offsets now live inCalendarEventInviteList_LayoutRowColumns, called from the resize pass and fromCalendarEventInviteList_InitButtonShared, so every row is laid out at the list's current width however it was acquired. Location:ClassicCalendar.lua. (BUG-030)Why no test caught it, which is the part worth recording.
CalendarEventFrame_UpdateWidthcannot run offline at all — it raises onScrollBox:GetFrames(), which the harness does not model — so the resize path had never executed in the suite. Worse,CalendarEventInviteList_InitButtonSharedalso died on its first status lookup, becauseCalendarUtilandCALENDAR_INVITESTATUS_INFOare real Classic Era client globals (Blizzard_FrameXMLUtil/CalendarUtil.lua,Blizzard_FrameXMLBase/Shared/Constants.lua) that nothing offline supplied. The busiest row-rendering function in the addon had zero offline coverage, and both halves of the column layout sat behind that.Tests/env_cc.luanow models both globals from the client source, andTests/invitelist_spec.luaadds three specs — a freshly acquired row is laid out at the current width, two rows acquired at different times agree with each other, and a row can find its owning list — all parameterised across the Create and View windows. Mutation-verified: removing the initializer's layout call fails four of them.GetFrames()is raised inTests/HARNESS_CONTRACT.md, deliberately without a local stub, since a stub returning{}would make the resize pass appear to run while iterating nothing.Corrected four of ten invite-status colours in the offline test environment, and added a spec that reads Blizzard's source so they cannot drift again.
CALENDAR_INVITESTATUS_INFOwas hand-transcribed intoTests/env_cc.luaand Standby, Signed Up, Not Signed Up and Tentative all defaulted toNORMAL_FONT_COLOR— the client usesORANGE,GREEN,GRAYandORANGE. Nothing failed, because a wrong model does not crash: the obvious spec ("a signed-up raider's row is coloured correctly") would have asserted the model's answer and then passed through a real regression that flattened every status to normal. NewTests/clientmodel_spec.luaparsesBlizzard_FrameXMLBase/Shared/Constants.luaoff disk and asserts the model points at the same colour global and the same name string for every status, with a guard asserting the parse found all ten so it cannot go vacuous. No player-facing effect — this is test-environment only. (Peer-review finding 5)Deleted two dead
Enumshadows fromTests/env_cc.lua.Enum.CalendarStatusandEnum.CalendarEventTypewere written as_G.Enum.X = _G.Enum.X or {…}, and the harness (env/wow.lua:630-636) installs both first — so the local literals never applied. They had also gone stale: ours was missingHeroicDeprecated = 5, which the client and harness both carry. Found by mutation-testing the new enum spec and getting a pass — editing the literal changed nothing because the literal was dead, which is exactly the "verify the mutation actually landed" trap.clientmodel_spec.luanow asserts everyEnum.CalendarStatusvalue againstCalendarConstantsDocumentation.lua, mutation-verified at runtime rather than by editing a definition.Added a spec asserting the unsuffixed
ClassicCalendar.tocexists at all. The client triesAddonName_<Flavour>.tocfirst and falls through toAddonName.toc; with no unsuffixed file there is nothing to fall through to and the addon never loads on any flavour it does not explicitly name — not "loads with the wrong interface", never loads. Nothing in the suite caught that: the suffix check accepts any file that is base-or-suffixed, so two correctly-suffixed TOCs and no base would have satisfied every other assertion. Pinned after a fleet-wide sweep found real addons in exactly that state. Mutation-verified.The TOC specs now discover the manifests instead of listing them.
Tests/tocorder_spec.luaiterated a hardcodedTOCStable, so every guarantee in it — load order, files-exist, and the "all TOCs carry an identical file list" rule — silently skipped any manifest not named in that literal. Adding a flavour and forgetting to edit the spec left the new file checked by nothing, which is the same shape as the defect the file exists to catch. The list is now globbed from the addon root, with a guard asserting discovery found at least two so the whole file cannot pass vacuously, plus a new check that every TOC's suffix is one the client actually recognises —_BCCis deliberately absent from that set, being dead since Classic Anniversary 2.5.5 and the reason the BCC manifest went unloaded here for two releases. (Peer-review finding 4)Fixed Zul'Gurub and Ahn'Qiraj Ruins showing their reset an hour late (US) or two hours early (EU).
GetClassicRaidResetsapplied aregionHourAdjustment(+1, or-2on EU) to the two 3-day raids only, so on a realm whose server reset is 07:00 the calendar showed Molten Core, Blackwing Lair, Naxxramas, AQ Temple and Onyxia's Lair at 7:00 AM while Zul'Gurub and AQ Ruins claimed 8:00 AM. A lockout's frequency differs between raids; its time of day does not. The adjustment is removed and every raid now uses the region's server-reset hour. Confirmed against the live calendar before changing anything, because guessing at reset data would put every ZG and AQ20 reset on the wrong hour for every player — and re-tested in the client afterwards, showing 7:00 AM across the board. Location:HolidayData.lua. (BUG-029)The suite was ratifying this bug, which is the more useful half of the story. A spec named "offsets the 3-day raids by an hour on NA, and back two on EU" asserted the defect, justified with "ZG / AQ Ruins run on their own cycle and do not share the weekly reset hour" — and the hour specs beside it filtered to
frequency == 7, so they deliberately looked away from the only two entries that were wrong. The bug was therefore unreachable by running the tests. Replaced with specs asserting every raid shares the server hour on US, EU and KR, plus a guard asserting the reset table actually contains both frequencies so the assertion cannot go vacuous if a 3-day raid is ever removed. Re-baselining an assertion onto different behaviour needs a stated reason, and the reason is recorded in the spec itself. Mutation-verified: reintroducing the+1fails four specs.Added
Tests/tocorder_spec.lua— 14 specs on the load-order guarantees that live in the.tocfiles, where no line counter can reach them. Prompted by a harness review-method entry: a line counts as covered the moment its condition is evaluated, not when it is true, so a guard can sit at 100% having never once fired — and a precondition expressed in a text manifest cannot be reached at all. This addon had four such preconditions and nothing asserted any of them: the locale table before the three files readingCLASSIC_CALENDAR_Lat file scope;LibDataBroker-1.1beforeLibDBIcon-1.0;ClassicCalendar.luabeforeGuildRankConfirm.lua(whose file-scopehooksecurefuncraises on a nil target and takes the rest of the file with it); andPatches.luabeforePatches.xml. Also pinned: every listed file exists, both TOCs carry an identical load list (theCLAUDE.mdrule, checked by hand until now — which is how the BCC TOC sat a patch behind for two releases), and each TOC declares an interface in its own flavour's range, pinning the flavour rather than the patch so a version bump doesn't fail it.It catches the bug that actually shipped. Deleting the
LibDataBroker-1.1line — reintroducing the defect that left players with no minimap button — fails three specs, including the one that names it and the TOC-drift check. That defect was invisible to every existing test because it lived in a file the suite never read.Added
Tests/invitelist_spec.lua— the invite-list render path is now covered, closing the verification owed for two harness contracts. Five specs onCalendarCreateEventInviteListScrollFrame_Update, the addon's busiest code path (it re-runs on every invite-list update) and where the signup-order numbers and online/offline column reach the player. Pinned: one element per named invite, an unnamed invite skipped with the numbering closing up behind it, an empty list still replacing the previous provider rather than leaving stale rows, and nothing at all happening until the server has resolved the names.The assertion reads the provider back off the ScrollBox with
GetDataProvider()rather than capturing one by wrappingCreateDataProvider. That distinction is the whole point: a wrapped constructor proves the function built the right list and says nothing about whether it ever reached the widget — which is exactly how this path was nearly reported as working earlier, whenfireEventraised nothing while zero providers were built.Verified by mutation, and the first attempt was invalid — which is how the second gap was found. Removing the named-invite filter left every spec green, because there are two near-identical copies of this function and the mutation had landed on the View list (
ClassicCalendar.lua:3966) while the specs drove the Create list (:4824). Re-run against the right line, both it and the names-ready guard produce clean failures. Worth stating as a rule: "the mutation didn't fail the suite" has two readings, and only one of them means a weak spec — twice this session it did, this time it meant a bad mutation.So the spec now runs against both lists, parameterised over the two, and the mutation that was silently green now fails. The duplication itself is left in place deliberately: the two functions differ only in frame names and one boolean, so merging is tempting, but this addon is a fork meant to stay re-mergeable with upstream Blizzard changes and
CLAUDE.mdasks for additive patches over edits to the ported source. Parameterising the spec closes the risk — two copies that must stay in step, exercised on one side only — without making the next upstream merge harder. Both the gap and the reasoning are recorded indocs/AUDIT.mdso a reviewer proposing the merge weighs it against that constraint rather than treating the duplication as unexamined.Fixed guild-wipe data resurrecting itself on a connected-realm cluster, and permanently desyncing that member. Found by the first peer review (
docs/AUDIT.mdfinding 1) and verified here before acting. Every path that introduces world-buff entries filters on the guild wipe epoch —Migratereads it atWorldBuffDB.lua:744and filters at:767,ApplyEntriesatWorldBuffSync.lua:299/:309— exceptAdoptRealmScopedStores, which contained no reference toGetWipeAtat all and last-writer-wins-merged the old realm-scoped store straight in. The function's own docstring claimed it used "the SAME last-writer-wins rules the sync uses"; it did, but the sync's rules are LWW plus the wipe filter and only half had come across.The damage is worse than a stale row. An officer wipes the guild; a member still holding a pre-
Faction-GuildNamestore logs in and adopts entries stamped before the wipe. Those rows are in their roll-up and nobody else's, so their item hash differs from the entire guild permanently —ClearEntriesBeforeonly re-runs when a newer epoch arrives, so it never self-heals, and every offer they make is rejected by each peer'sinc > wipeAt, so the offer/reject exchange repeats for the rest of the session with nothing raised anywhere. That is precisely the divergence this addon's hashing design exists to prevent.Fixed by reading
GetWipeAt(gkey)once and rejectinglastModified <= wipeAtbefore the last-writer-wins comparison — the ordering matters, or a cleared row still slips through theif not cur then apply = truebranch when the current store has no row for that key. The settings loop is untouched;MergeSettingRecordwas already wipe-aware. Spec:Tests/wbintegrity_spec.lua, "does not resurrect entries the guild wipe already cleared", which plants one row either side of the epoch so it fails both if the filter is missing and if it is applied too broadly. Location:WorldBuffDB.lua.Fixed duplicate guild events being created three minutes apart across an hour boundary.
CalendarHelper:EventAlreadyExistsdocumented a "within 5 minutes tolerance" window but implemented it aseventTime.hour == hour and math.abs(eventTime.minute - minute) <= 5— so the tolerance collapsed at every hour boundary and 19:58 versus 20:01 read as two different events. Now compared as minutes-into-the-day. Deliberately not wrap-aware, and that distinction was a mistake caught while writing the specs: both times belong to the same day, so 23:58 and 00:01 are twenty-four hours apart, not three, and wrapping would have silently refused a legitimate late-night raid because an early-morning one existed. Ten new specs cover the window, the title match, calendar-type filtering, and both non-duplicate directions. Location:CalendarHelper.lua.Adopted the peer-review protocol: added
docs/AUDIT.md. It is the inverse of a harness contract — a contract is raised by this addon and answered by the harness in its repo; an audit is raised by a review session and answered by this addon, in ours. Append-only in both directions, with a fixed finding answered in place rather than moved to a "Resolved" section, because a finding's value is its failure scenario sitting beside the code it describes. Three adoption steps done: the file created from the template, a pointer added toCLAUDE.md(the load-bearing one —CLAUDE.mdloads unconditionally, whereas the watcher only catches writes made while a session is already running), and the session watcher widened to\( -name HARNESS_CONTRACT.md -o -name AUDIT.md \). Verified both ways: the watcher now listsClassicCalendar/docs/AUDIT.md, and a-DryRunof the replication script shows it as[skip], so an audit — a list of this addon's defects and how to reproduce them — cannot reach players.docswas already in.pkgmeta'signore:.No review has audited this addon yet, so the file starts with empty Findings. It is not empty of content: it carries the measured per-file coverage a first reviewer would otherwise have to derive, and two entries under Checked and correct —
CalendarTodayView_NavigateDay's date arithmetic and the world-buff row comparator, both of which look like the defect families found elsewhere in this addon and are genuinely sound. That section exists precisely so the next reviewer does not spend the same hours reaching the same relief.Fixed a potential client freeze in the Today view.
CalendarTodayViewFrame_Updateturned the navigated date into a month offset by searching: step the offset by one, re-askC_Calendar.GetMonthInfo, repeat until the month and year matched. That loop had no bound and no exit other than the API agreeing — andGetMonthInfois documented with no stated range foroffsetMonthswhile the calendar is a bounded window, so an offset the client declines to honour returns the same month forever. Reproduced against aGetMonthInfoclamped to ±2 months: still running after two million VM instructions. The Today view can be navigated a day at a time with no limit, so the date it holds can be arbitrarily far from the displayed month. The offset is now computed as the difference between two absolute month indices — exact, constant-time, and with nothing to search for. Location:CalendarTodayView.lua.Honestly scoped: I have not confirmed that the live client clamps
offsetMonths— the documentation states no range either way. The fix stands on the loop being unsafe by construction rather than on a confirmed client behaviour, and it also removes an O(n) round-trip per render for free.Fixed a new spec silently switching coverage measurement OFF for every spec file after it.
Tests/todayview_spec.lua's hang guard installed a debug hook and then cleared it with a baredebug.sethook()— which removes whatever hook is installed, andTests/wowapi/coverage.luameasures with a line hook. The suite stayed green throughout; only the reported percentages moved, and they moved enormously: WorldBuffDB read 42.86% in a full run against 87.63% when its own specs ran alone, and 0% with the offending file placed immediately before them. It now saves and restores the previous hook withdebug.gethook(). Numbers that quietly become fiction are worse than no numbers, because nothing fails and there is nothing to point at.Worth recording how it was caught, since the first two hypotheses were both wrong: the figures moved in both directions at once (WorldBuffDB down, HolidayData up), which looked like the harness's coverage perf change that had just been adopted. Running the previous
coverage.luaagainst identical code gave byte-identical output, which cleared the tool. Measuring the world-buff files against only their own specs reproduced the original numbers, which said the leak was ordering-dependent. Bisecting one spec file at a time named it. The corrected total is 52.89%, andHolidayDataat 92.57% is a genuine rise from the new date specs rather than an artefact.Added
Tests/todayview_spec.lua— 7 specs, including two that must fail rather than hang. The offset is checked against an absolute-month oracle across a four-year span, and two specs drive a deliberately clamping client. Those two run under a bounded-instruction guard, which was not my first instinct and is the lesson worth keeping: the termination spec was guarded but the "reports the true offset when clamped" one was a plain call, so restoring the old loop to verify the guard took the whole suite past its timeout instead of failing one example. A suite that hangs reports nothing at all, which is strictly worse than one that fails. With both guarded, the mutation now produces two clean failures.Fixed the calendar substituting its own event index for the client's on player-created events.
stubbedGetEventIndexcompared againststate.presentDate.currentMonthOffset, butpresentDateholds onlyyear/month/day— the offset lives atstate.currentMonthOffset, which the very next line uses correctly. So the comparison always read nil, the "the client already has an index and it agrees with us, hand it back" branch could never be taken against a client that had one, and the synthesised table was returned instead. That matters specifically for player-created events, which are the one part ofC_Calendarthe classic client gets right: the client's own index object is what the rest of Blizzard's calendar code compares against, so substituting ours can select the wrong event. Location:Patches.lua.Fixed month stepping in the calendar for any offset other than ±1.
adjustMonthByOffsetdidmonth = month + offsetand then corrected withif month > 12 then month = 1(remainder discarded — December plus two landed on January) andelseif month == 0(so an offset below −1 matched neither branch and leftmonth = -1, out of range and never wrapped). This is reachable rather than theoretical:ClassicCalendar.lua:2039callsstubbedSetMonthwithGetGuildEventSelectionInfo().offsetMonth, a server-supplied value that is however many months away the guild event is — so following a guild-event link to a raid three months out took the broken path. Replaced with arithmetic on an absolute month index, verified againstos.time's own normalisation across 3,660 combinations spanning offsets −30 to +30. This was the third copy of this same defect in the codebase, after the two inWorldBuff.lua. Location:Patches.lua.Noted, not fixed:
state.presentDate.monthand.yearare written but never read. Four places assign them and nothing consumes them, which is why the month-stepping defect above had no visible symptom despite being reachable. The arithmetic is now correct regardless, but the fields are dead state — either something that should be reading them isn't, or they should go. Flagged rather than removed, because deleting state on the strength of a grep is how a subtle read gets missed.Fixed the world-buff date pickers keeping a day the new month does not have.
ChangeMonthandChangeDatePickerMonthadvancedmonthand leftdayuntouched, so stepping from the 31st onto a shorter month left the dialog holding a date like 31 February. The calendar grid redrew correctly — it is built from month and year — so nothing looked wrong on screen while the date the guild event was actually created from was the corrupt one. Both steppers now clamp the day to the last real day of the target month (29 February in a leap year, not 28), and the day clamp runs after the ±1-year window clamp because that one can itself move the month. Location:WorldBuff.lua.Fixed month navigation ignoring anything but a single step. The same two functions did
month = month + deltaand then corrected withif month > 12 then month = 1, which throws the remainder away: December plus two months landed on January rather than February, and January minus two on December rather than November. Replaced with arithmetic on an absolute month index, which is correct for any delta by construction. Both functions were separate copies of the same code and carried both defects; they now share one helper, so the next fix happens once. Location:WorldBuff.lua.Fixed
GetNthSundayOfMonthreturning days that do not exist. It answeredfirstSunday + (n - 1) * 7with no bounds check, so asking for a fifth Sunday in a month that has only four returned day 32, 33, 35 or 36 — in 125 of the 192 months between 2020 and 2035. Those look like ordinary day numbers and silently become a date in the following month as soon as anything passes them totime(). It now returns nil when the month has no Nth Sunday. The two live callers (the Pacific DST rules, which ask for the 2nd Sunday in March and the 1st in November) can never get nil, so no behaviour changes today — this closes a trap for the next caller. Also removed a deadif firstSunday == 8branch that the formula makes unreachable. Location:CalendarHelper.lua.Removed two phantom fields from three world-buff date reads.
entry.receivedDate or entry.playerNameedDate or entry.droppededDateappeared in the row sorter, the row renderer and the edit dialog. Neither fallback is a field — both are the residue of a rename that replacedreceivedinsidereceivedDatetoo, so they always read nil. Harmless, but they implied the entry format had three date fields when it has one. Location:WorldBuff.lua.Fixed the Officer options tab losing every setting below the "Restrict Guild Events" checkbox.
ClassicCalendar_Options.luacreatedchkRestrictGuildEventsTextwithCreateFontStringand then calledSetScript("OnEnter"/"OnLeave"/"OnMouseDown")andEnableMouse(true)on it. A FontString is aLayeredRegion, not aScriptObject— Blizzard's own API documentation declares both methods only onSimpleScriptRegion— so the first of those calls raisedattempt to call method 'SetScript' (a nil value). It ran at file scope, which means the error aborted the rest of the file: every option defined after line 879 was never created, on every login, for every player. The tooltip and click-through now live on a mouse-enabledFrameanchored over the label, which is the pattern the other four checkboxes in that file already used. Found by building the addon's real frames offline (Tests/framexml_spec.lua). Location:ClassicCalendar_Options.lua.Fixed the minimap button failing to appear for players with a small addon collection. Both TOCs loaded
Libs\LibDBIcon-1.0\LibDBIcon-1.0\LibDBIcon-1.0.luawithout first loading theLibDataBroker-1.1copy embedded alongside it, so LibDBIcon hit its ownerror(DBICON10 .. " requires LibDataBroker-1.1.")guard on line 12. The failure was invisible during development because LibStub is shared process-wide and 42 other installed addons register LibDataBroker-1.1 before ClassicCalendar loads — anyone whose addon set happened to include one of them was fine, and anyone else got a Lua error and no minimap icon.Libs\LibDBIcon-1.0\LibDataBroker-1.1\LibDataBroker-1.1.luais now listed immediately before LibDBIcon in bothClassicCalendar.tocandClassicCalendar_BCC.toc. Location:ClassicCalendar.toc,ClassicCalendar_BCC.toc.Fixed seasonal holidays drifting an hour earlier on every schedule rebuild.
addHolidayToSchedule(HolidayData.lua) assigned its DST-normalised dates back into theholidayargument — which is an element of the module-level static tables (CLASSIC_CALENDAR_HOLIDAYS,WeeklyHolidays, the Darkmoon schedules,battlegroundWeekends), not a copy. The adjustment was therefore cumulative: every regeneration re-read the already-adjustedstartDate/endDateand calledadjustDSTon them again, subtracting another hour.GetClassicHolidaysrebuilds its cache on a day rollover, on/calrefresh, and whenever the schedule exceeds 500 entries, so a long session or repeated refreshes walked every DST-period holiday backwards an hour at a time (Noblegarden09:00 → 08:00 → 07:00, likewise Children's Week, Midsummer, Harvest Festival, Hallow's End). Fix:addHolidayToSchedulenow takes aCopyTableof its argument, making it pure with respect to its input.getSoDEventscarried the identical defect — it shiftedSoDEventsin place, compounding the regional hour offset on every call — and now builds copies too. Found by the new offline suite (Tests/holidaydata_spec.lua, "produces an identical schedule when regenerated"). Location:HolidayData.lua.Fixed two mangled locale keys that silently reverted Russian and Chinese calendar filter text to English. A mid-file edit had welded two adjacent keys together in
Locales/localization-world.lua:AuthorHeaderTextandCALENDAR_FILTER_HOLIDAYSbecameAuthorHeaderTextHOLIDAYS(holding the filter translation —Праздники/节日) plus an empty leftoverCALENDAR_FILTER_, in both theruRUandzhCNtables. BecauseClassicCalendar_Options.lua'scheckLocale()backfills any missing or empty key fromenUS, the realCALENDAR_FILTER_HOLIDAYSresolved to"Holidays"on those clients while the correct translation sat under a key nothing ever asked for — invisible in the file and invisible in game. Also correctedesES's"Guild SettingsDesc"(a space in the key) toGuildSettingsDesc. Location:Locales/localization-world.lua.Fixed
WorldBuffs:SanitizeEntrywriting the string"No"where the store's contract is a boolean.WorldBuffDB's entry format documentsdroppedas a canonical bool (never"Yes"/"No"), and both the leaf hash (e.dropped and "1" or "0") and the wire codec (e.dropped and true or false) rely on that.SanitizeEntrysetcleanEntry.dropped = "No"for a not-dropped buff — a truthy value in Lua — so any entry passing through it would read back as dropped and hash differently from every peer. Only the dormant legacy per-character arrays currently run through this path (CleanupCorruptedData), which is why it never surfaced; it is nowcleanEntry.dropped = cleanEntry.dropped and true or false. Location:WorldBuff.lua.
Testing
🎯 The addon now loads every one of its files offline — the TOC sweep reports
OK ClassicCalendar 15/15, given the client source root. It began this work at 9/14 and the worst-loading consumer of the shared harness. Getting there took two real addon fixes (theSetScript-on-a-FontString crash and the unloaded LibDataBroker) plus eleven harness contracts raised from this addon, the last of which —GameTimeFrame, delivered indc5947a— was the final blocker atPatches.lua:1156. The localGameTimeFrameandCLASS_ICON_TCOORDSstand-ins are deleted, and 15/15 still holds with them gone, which is what proves the harness is carrying it rather than our own shadows.The condition is load-bearing and the number is 14/15 without it:
lua Tests/wowapi/tools/verify-addons.lua <AddOns> ClassicCalendar "F:/Blizzard API Docs/wow-ui-source-classic_era"Omit that third argument and
ClassicCalendar_Options.lua:796stops oncopyPopup.TitleText, aparentKeypublished by Blizzard'sBasicFrameTemplateWithInset— the templates only resolve when the builder has the client's own XML to read. That is not a defect in either the addon or the harness (the documented invocation passes the root), but "15/15" unqualified would be wrong on any machine without that source tree, so the qualification travels with the number.Adopted
C_DateAndTimeandCreateDataProviderfrom the harness (9bc5fe8), and deleted the last local stand-in. Both were requested with the reasoning stated rather than just the shape, and both shipped as specified — including the decision thatC_DateAndTimederives from the driven clock whileGetGameTimedeliberately does not.GetServerTimeLocalandGetSecondsUntilWeeklyResetare still supplied here, but added onto the namespace rather than over it: assigning aC_*table wholesale destroys everything else in it, which is a hazard the harness has watched two other addons hit.Making
C_DateAndTimethe harness's broke a spec, and the reason was the same defect this addon had just reported to the harness.holidaydata_specrolled the day by assigningcc.now.monthDay, which worked only whilecc.nowwas an independent authority the namespace read directly. Once the namespace derived fromwow.epoch, those were two clocks and the write silently did nothing — the "each clock individually plausible, wrong only in their relationship" failure, sitting in our own test env while being written up as a contract.cc.nowis now a derived read of the harness clock whose__newindexraises with an explanation, so the two cannot drift and the old idiom fails loudly instead of passing quietly. Location:Tests/env_cc.lua,Tests/holidaydata_spec.lua.Verified the
CreateDataProviderdelivery properly, after nearly reporting it wrong. FiringCALENDAR_UPDATE_INVITE_LISTproduced no error, which looked like success — but wrapping the constructor to count calls showed zero providers built: the event handler gates on frame visibility and never reached the render function, so "did not raise" meant "did not run". CallingCalendarCreateEventInviteListScrollFrame_Updatedirectly shows the real state: it now runs end to end — names-ready check, signup-order refresh, invite collection, custom sort, provider built and filled with one element per invite (2 for two signups) — and stops on the next line at the ScrollBox. So the delivery moved the wall by the whole body of the function, and the remaining blocker is one call.Adopted the
ScrollBox:SetDataProviderdelivery (7c0b169) and found it unreachable from the path this addon uses. The implementation is correct —CreateFrame(..., "WowScrollBoxList")returns a frame with a working setter and getter. But this addon declares its ScrollBoxes in XML (ClassicCalendarTemplates.xml:272and three named frames inClassicCalendar.xml), and XMLinherits=resolves templates against a different registry thanCreateFramedoes: withregisterBlizzardTemplates()called, Blizzard's ownWowScrollBoxListdeclaration wins and the frame is built without the behaviour. Isolated to a single variable — the same XML build yields a working setter with the Blizzard templates not registered, and nil with them registered, the unresolvedScrollBoxBaseMixin/ScrollBoxListMixinbeing the tell. The consequence is that the two capabilities delivered this week are currently mutually exclusive here: Blizzard template resolution (needed forClassicCalendar_Options.lua:796, and for 15/15) or a working ScrollBox, not both. Raised as a contract with the reproduction; the verification spec is deliberately not written yet, because one that passed againstCreateFrame-built frames would be asserting a path the addon never takes.That precedence gap is now fixed (
37bdb71), and the invite-list render path is unblocked. Aframes.templatesbuiltin wins on the XMLinherits=path too, not only viaCreateFrame. Verified here: the probe that returnedSetDataProvider = nilwith Blizzard's templates registered now returns a function, with zero unresolved references. The peer-review boundary was corrected in the same window — a review session writes findings straight into the addon'sdocs/AUDIT.mdand touches nothing else there, which is the reverse of the rule adopted an hour earlier.CLAUDE.mdanddocs/AUDIT.mdwere both corrected immediately, since a stale rule in an auto-loaded file is the expensive kind.Raised
ScrollBox:SetDataProvideras the next contract, with the measurement rather than a guess. Scoped deliberately to a setter and a getter, with what is not being asked for listed explicitly (no scrolling, no frame recycling, noScrollUtil, no element factory) so the boundary is on the record before anyone implements to it. This is the addon's busiest code path — it re-runs on every invite-list update — and none of it is assertable until that lands.Adopted two further harness commits (
ac38ad8,3206e75), which addwow.setBuild(flavour)and every flavour's build info. Independently useful confirmation of the TOC audit above: the harness's Classic Era default moved 11508 → 11509 in the same window, derived from the per-flavour TOCs three other addons ship and verified in exact agreement across all three.Added four specs on
stubbedGetEventIndex, which is what found the substituted-index defect above. The one that fails against the old code asserts object identity — that the client's own table is handed back, not a copy carrying the same numbers — because a copy passes any field-by-field comparison while still being the wrong object to hand to Blizzard's calendar code. The other two pin the negative cases (a stale client index for a different day must not be used; no client index at all must synthesise one).Added
Tests/rankautoconfirm_spec.lua— 14 specs on the Rank Auto-Confirm decision, the highest-consequence logic in the addon. It confirms guild members into a raid without asking, so a mistake is not a visual glitch — it is the wrong people in a real raid night, with nothing to prompt the officer to look. The specs drive the addon's own event frame with a realCALENDAR_UPDATE_INVITE_LIST, because the decision is gated three times before it reaches the rank comparison (which calendar window is open, the player's permission, and the event being a guild event) and a spec calling the comparison directly would prove none of those gates work. Assertions are on what the addon did — the recordedEventSetInviteStatuscalls — not on a return value, since code that decides correctly and then fails to send would pass the latter. Pinned: an empty selection means nobody rather than everybody; onlySignedupinvites are actionable, so a decline is never overridden; a non-guild-member signup cannot match a rank; rank index 0 (Guild Master) is looked up as a key rather than tested for truth; each event's selection stays keyed to that event; and one server rejection does not abort the remaining signups. No defects found — the gates are correct.Verified the new specs by mutation, and found one of them passing for the wrong reason. Deleting the guild-event gate was caught immediately. Deleting the officer gate was not: the test meant to pin it set both "cannot edit" and "not an officer", so
ProcessSignups' own internal permission check stopped the run either way and the outer gate was never the reason the test passed. Added a case that isolates it — a non-officer who can edit the event, where only the outer gate can refuse — which does fail under that mutation. Recorded because it is the second time in this suite that a test has needed a deliberate break to prove it was testing anything.Extracted
WorldBuffs:SortEntriesForDisplayfrom the row renderer, and addedTests/wbordering_spec.lua. The row order is a correctness property, not a cosmetic one: the entries are replicated, so two guildmates whose stores have converged must see the same rows in the same sequence. The order comes from sorting the values of a hash table, andpairs()visits a Lua table in an order that depends on its internal layout — so the input sequence genuinely differs between two clients holding identical data, and only a total comparator stands between that and divergent windows. The 10 specs assert the property (same content, same output order, across six different insertion orders) rather than a hard-coded sequence. Deleting the final map-key tiebreak makes them fail with exactly the real-world symptom: one client renderingdup-a, dup-band anotherdup-b, dup-a. The sort was already correct; separating it from the rendering is what made it assertable at all, since the old code needed a liveScrollFrameto reach.Added
Tests/datepicker_spec.lua— 14 specs written against independent oracles, which found four defects. Every assertion is checked against a value derived by walking real dates (os.date/os.time) rather than by repeating the implementation's own arithmetic, because a test that re-derives the same formula ratifies the bug instead of catching it. That is what surfaced the day-clamp, multi-step and Nth-Sunday problems above: the month-navigation specs assert only that the resulting date exists, and the Nth-Sunday specs compare against a month walked day by day. Notable properties now pinned: stepping twelve months from the 31st never produces an impossible date at any point (a single-step test misses a clamp that only corrupts on the second hop), both pickers obey the same rules (they are separate code paths and divergence between them is exactly what a shared spec catches), and every day the Sunday helper returns really is a Sunday.Recorded that the leap-day path in the date picker is unreachable most years. The picker only offers last year through next year, so with the suite's fixed 2026 date no leap year is selectable at all; the spec has to steer the clock to 2027 to reach February 2028. Worth knowing rather than working around silently — it means the 29 February branch sees a real player roughly one year in four.
The addon's real UI is now built offline — all 16 TOC entries load, including both XML files. Adopted 82 commits of the shared WoWAPITesting harness (
7bdaeab→ee0c796), which broughtenv/framexml.lua: a FrameXML builder that constructs actual frames fromPatches.xmlandClassicCalendar.xml, resolving the ~2749 templates they inherit from Blizzard's own Classic Era source.Tests/framexml_spec.luabuilds 11 top-level frames and 19 virtual templates and asserts two properties that matter more than any count: that nothing of ours is left unresolved, and that no handler of ours raises. Unresolved Blizzard mixins (ScrollBarMixin,NineSlicePanelMixin, …) are excused by name, never by count, so a newly-broken reference cannot hide inside a tolerance. That pair is what caught theSetScript-on-a-FontString crash and the missingRaiseFrameLevel.ClassicCalendar_Options.luawent from "excluded as frame-bound" to ~60% line coverage andCalendarTodayView.luato ~24%; the suite is 344 specs, all passing.Deleted every stand-in
Tests/env_cc.luaheld that the harness now owns. The suite was green against roughly 60 locally-defined globals —time/date, the whole Lua-4 compat layer,print,SendChatMessage,GetCVar,C_Timer,C_AddOns,SOUNDKIT,Minimap,UIPanelWindows,StaticPopup*,Settings,SettingsPanel, theUIDropDownMenufamily and the frame factory — each of which shadowed the harness's version, so the tests exercised our copy while the shipped code runs against the client's. Two were actively wrong: the localsin/cosweremath.sin/math.cos(radians) where WoW's are degree-based, and the hand-rolledstrsplitdropped a trailing empty field.env_cc.luanow defines only the calendar surface the harness deliberately doesn't model, and opts the whole suite into the harness's real widget layer (env/frames.lua) — a suite-wide decision, because AceGUI and the addon's own files captureCreateFrameas an upvalue at load.All clocks now agree on the date.
cc.nowdrives the harness'swow.epoch, sotime(),date(),GetServerTime()andC_DateAndTimecannot disagree about what day it is;cc.cvars/cc.printed/cc.chatSent/cc.timersare aliases for the harness's own recorded tables rather than duplicates.Fixed the test environment silently disabling the mass-invite window.
env_cc.luamodelledC_Calendarwith an__indexfallback returningfunction() end, soC_Calendar.GetDefaultGuildFilter()returned nothing andCalendarMassInviteFrame_OnLoaddied onfilter.minLevel. The API is documented in Classic Era as returning a non-nilableCalendarGuildFilterInfoof{minLevel, maxLevel, rank}and is modelled explicitly now. This was the harness's own "a permissive stub makes the wrong branch the only one that runs" lesson applying to our env.Raised six harness contracts, each verified against Blizzard's source before being written, and all six were answered the same day (
Tests/HARNESS_CONTRACT.md; harness commitf07f024, pin moved to4470088). Four delivered whole —GetGameTime,CreateColorplus the font-colour globals asColorMixininstances rather than plain tables,RaiseFrameLevel/LowerFrameLevel/RaiseFrameLevelByTwo, andUIPanelButton_OnLoadwith threeCALENDAR_*GlobalStrings. Two were declined on this side's own reasoning and are recorded as decisions, not gaps: a numericSettingscategoryID(the registry is keyed on it and AceConfigDialog reads it back directly, soGetID()alone was shipped) andCreateScrollBoxListLinearView(stubbing a function whose real body does layout work would invent behaviour — the spec excuses it by name instead, which is honest about what is not covered).The highest-severity contract turned out to be a regression in the harness, not a missing feature. Its steerable
time()had been introduced the previous day and dropped its argument, sotime({year=…, month=…})returned the current clock instead of converting the date. That silently collapsed all nine of this addon'stime({...})call sites onto "now" — every computed holiday, Darkmoon window, battleground weekend, raid reset and DST boundary came back as the current day, with nothing raising. It surfaced here only because 16 date assertions went red on adoption; it is now the lead item in the harness's adoption log, worded as a direct warning, because an addon doing date arithmetic without such specs would have adopted it and never known. All six local stand-ins are now deleted, including thetime()wrapper — it probed before wrapping and would have yielded on its own, but a wrapper that yields silently is one nobody re-reads.Raised one further contract at the harness's request:
C_DateAndTime, which it found while verifying the six and reported rather than implementing unasked. It is the last stop in the TOC sweep (Patches.lua:111, at 12/15) and has 17 call sites here. The request answers the harness's explicit question about which clock it should read — derived from the driven epoch, the opposite call fromGetGameTime, because a full date that drifts fromtime()makes every "is this holiday today" comparison untestable, whereasGetGameTime's hour/minute models a realm-versus-local divergence that genuinely exists.Added a multi-peer world-buff network to the test environment, and a data-integrity suite on top of it.
Tests/env_cc.luacan now hold several guild members in one Lua state (newPeer/asPeerswap each peer'sWorldBuffDB/WorldBuffSyncstate around its turn) wired to a loopback network shaped like DeltaSync's collect/dispatch cycle, so peers exchange real messages and are asserted to converge.pumpNetworkraises on a message storm rather than looping, which is what turns "peers agree on content but not on its hash" — a bug that is silent in game — into a hard failure.Tests/wbconvergence_spec.lua(25 specs) covers live push, catch-up, relay through a third member, crossing edits, tie-breaks, the guild wipe against an offline member, hostile senders and old-protocol traffic.Tests/wbintegrity_spec.lua(30 specs) asserts the opposite property — that nothing mutates a record — pushing distinctive values through every read, serialise, merge and relay path and deep-comparing with a differ that names the offending field; it also pins the domain constraint that a player has exactly one row per buff type (a world buff drops once per player, which is why there are four tables) so a re-record is an edit rather than a new event.Tests/wbhash_spec.lua(29 specs) proves the canon is never re-derived by planting a knowingly wrong hash and asserting it survives every path — deliberately stronger than comparing against the correct value, which a recomputing path would pass.Verified the suite by mutation rather than by assuming it works. Each guard was checked by deliberately breaking the code it protects and confirming the failure, with the mutation verified as applied first: recompute-on-receive, recompute-on-index-build, dropping a field from the wire codec, coercing a boolean to a string, lower-casing a display name, aliasing the wire payload into the store, dropping
setBy, keying a row per drop instead of per player, and removing key case-folding. This also caught two tests that were passing for the wrong reason — a stale-entry scenario that never exercised the live-push path (the catch-up diff was filtering it first), and afresh()handle captured across a store swap that compared one client with itself.Added an offline unit-test suite on the shared WoWAPITesting harness.
Tests/wowapiis the harness as a git submodule (pinned at7bdaeab);Tests/env_cc.luais this addon's environment layer, supplying the calendar surface the harness deliberately doesn't model —C_Calendar/C_DateAndTimewith a fixed calendar date, theCALENDAR_*string globals, Blizzard colour objects (ColorMixin-shaped, since the ported code calls:GetRGB()on them), theUIDropDownMenufamily, and a frame factory whoseCreateFontString/CreateTexture/CreateLinereturn real mock objects rather than the harness's blanket no-op, which is what lets the ported Blizzard files load at all. Real sibling libraries are loaded rather than stubbed:cc.readyGuildRoster()loads../GuildRoster/LibGuildRoster-1.0.luaand drives it through actualGUILD_ROSTER_UPDATEevents until it reports ready, andcc.ace.load()loads the installed Ace3. 237 specs, all passing, coveringWorldBuffDB(~89% line coverage),WorldBuffSync(~76%),HolidayData(~75%),Patches(~50%),CalendarHelper(~39%),GuildRankConfirm(~28%) andWorldBuff(~13%);ClassicCalendar.lua,ClassicCalendar_Options.luaandCalendarTodayView.luaare excluded as frame-bound (the options panel reads regions supplied by Blizzard XML templates, e.g.copyPopup.TitleText). Notable properties pinned: the sync layer's determinism under out-of-order merges and exact-timestamp ties, thestubbedGetNumDayEvents/stubbedGetDayEventcount-versus-index contract across four month offsets (a mismatch there trips the getter's ownassertand errors the calendar),HolidayDataloading successfully under all ten supported locales, andWorldBuffs:GetFirstDayOfWeek's hand-rolled Zeller's congruence checked against the C library for every month from 2020 to 2030. Run withlua Tests/wowapi/run.luafrom the addon root — Lua 5.1 only, no busted, no CI. SeeTests/README.md. Location:Tests/,.busted,.gitmodules,.pkgmeta,.luarc.json.Recorded that the legacy world-buff importer is structurally unreachable.
WorldBuffDB:Migratecallsself:Init()as its first statement, andInitstamps_dataFormat = WB_DATA_FORMATbeforeMigrate's own version gate is evaluated — so the importer body cannot run from any starting state, not merely from current data. Behaviour is unchanged and correct (the v1.6.0 data was corrupt and is deliberately discarded in favour of clean data from sync), but bumpingWB_DATA_FORMATin future will not bring the importer back. Pinned by a spec so that is discovered locally rather than in the field. Location:Tests/worldbuffdb_spec.lua.
Build / Packaging
- Renamed
ClassicCalendar_BCC.toctoClassicCalendar_TBC.toc, because_BCCis not a suffix the client recognises. Across all four of Blizzard's source trees they ship_Vanilla,_TBC,_Wrath,_Cata,_Mists,_Mainlineand_Classic, and zero_BCC.toc; among installed third-party addons 51 use_TBCagainst 12 using_BCC. So the BCC TOC was never loaded by any client and Burning Crusade players silently fell back to the Classic Era TOC — which is also why the v1.6.3 "shows as out of date on BCC" fix did not take. Renamed withgit mvso history follows the file. - Established that Wrath, Cataclysm and Mists are out of scope, and recorded why. TOCs for all three were drafted and then removed once the premise was checked: those clients have a working native calendar, and this addon exists only because vanilla and tbc do not. Blizzard's own
Blizzard_Minimap_Classic.tocdraws exactly that line —Classic\GameTime_NoCalendar.lua [AllowLoadGameType vanilla, tbc]againstWrath\GameTime.lua [AllowLoadGameType wrath, cata, mists]— so the client itself ships a no calendar minimap button on precisely the two flavours this addon targets, and our ownGameTime_Wrath.luais a port of the file on the far side of that gate. Shipping there would also have collided:Blizzard_CalendarisLoadOnDemandand declares the same global frame names this addon creates (CalendarFrame,CalendarViewEventFrame,CalendarCreateEventFrame, …), so a player opening the built-in calendar would have hit names already taken. The reasoning is now inCLAUDE.mdand.pkgmetaso it isn't re-litigated. Supported flavours remain Classic Era / SoD and Burning Crusade. - Bumped the Classic Era TOC from
Interface: 11508to11509to match the live client (.build.inforeportswow_classic_era 1.15.9.69109; 101 of the installed addons already declare 11509). The BCC TOC was already correct at20506. The.pkgmetacomment recording both numbers had drifted to the previous pair and is corrected, andCLAUDE.mdnow says to read the version out of.build.inforather than guessing. - Fixed
wow-version-replication.ps1copying files the packager excludes. Its glob-to-regex translation compiled*to[^\\]*, which cannot cross a directory separator — but the BigWigs packager matches with a shellcasestatement, where*matches any string including/. So.pkgmeta's"*.txt"droppedLibs/LibDBIcon-1.0/CHANGES.txtfrom the released zip while the dev-sync script kept copying it into the other flavour installs: the two implementations of the same exclusion rules disagreeing, which is the exact drift this script has to avoid to be useful.*and?now match separators as the packager's do. Verified with-DryRun, not by reasoning: the file moved fromWOULDto[skip]and nothing else changed. - Moved the comment out of
.pkgmeta'signore:list. The file's own header states that comments belong above the list, and then carried one inside it. The verified, documented hazard is a trailing comment on a list item —yaml_listitem()strips one quote but not the comment, leaving an unbalanced quote that makes the packager upload an empty zip while exiting 0. A standalone comment line inside the list is a different case and I have not verified how the packager's reader treats it; following the canonical form removes the question rather than relying on the parser's tolerance. - Added
Teststo.pkgmeta'signorelist (bare name, no trailing slash) so neither the specs nor the harness submodule reach the released zip. Removed the five dot-entries (.git,.github,.vscode,.luarc.json,.markdownlint.json) that were no-ops — the packager'scopy_directory_tree()prunes anything beginning with.before it consults the ignore list, so listing them implied coverage they weren't providing. - Fixed
wow-version-replication.ps1replicating dev files into the other flavors. Its always-skip list named git metadata individually; it now skips any path component beginning with., mirroring the packager's-name ".*" -pruneexactly. That is load-bearing rather than cosmetic — this repo's.gitis a one-line gitdir pointer file, so replicating it aimed the copy at the wrong repository — and it is the only thing keeping.busted,.gitmodules,.luarc.jsonand.markdownlint.jsonout of the synced installs now that dot-entries are (correctly) absent from.pkgmeta. Verified with-DryRun. Location:wow-version-replication.ps1.
Older releases
Releases v1.6.4 and older have been moved to CHANGELOG_ARCHIVE.md,
so that this file stays within the size GitHub accepts for a release body. The moves
also normalised those sections' punctuation to ASCII; the wording is unchanged.
Nothing has been deleted — the sections were moved whole, at version boundaries.
This mod has no additional files
