File Details
v1.3.12-bcc
- R
- May 17, 2026
- 232.22 KB
- 8
- 2.5.5
- Classic TBC
File Name
VCLoot-v1.3.12-bcc.zip
Supported Versions
- 2.5.5
# VibeCheck Changelog
## 1.3.12 — May 17, 2026
### Bug Fix — Per-Boss EP Override Inconsistently Applied
Reported symptom: "the first two bosses give the correct override EP, but the third boss reverts to the default `epPerBossKill`."
**Root cause.** The addon detects boss kills via two parallel signal sources, and they pass **different ID types** to the same `OnBossKilled` handler:
| Code path | ID passed |
|-----------|-----------|
| `OnEncounterEnd` (Blizzard encounter system) | Encounter ID (e.g. 652 = Attumen) |
| `OnCombatLog` (UNIT_DIED fallback) | NPC ID (e.g. 16151 = Attumen) |
The `bossEPOverrides` table is keyed by **NPC ID** (the UI builds it from `BOSS_LIST`, which uses NPC IDs). Whichever signal fired first won the dedup race:
- COMBAT_LOG fires first → NPC ID lookup → **override hits** → correct EP awarded
- ENCOUNTER_END fires first → encounter ID lookup → **silently misses** → falls back to default
Because the race resolution depends on timing (server response, addon load order, combat-log buffering), it manifests as "boss A is correct, boss B is wrong" inconsistently across the raid.
**Fix.** Two-step lookup with a name-based fallback:
1. Try `overrides[id]` directly — works when the COMBAT_LOG path wins or when the IDs happen to coincide.
2. If that misses, walk `BOSS_LIST` for an entry whose `name` matches the encounter name (case-insensitive). Use **that** NPC ID for the lookup.
3. If both paths miss, fall back to `epPerBossKill` (the previous default behavior).
`BOSS_LIST` has ~70 entries, so the scan is essentially free at boss-kill frequency. The fallback is locale-safe: encounter names from Blizzard's ENCOUNTER_END match the names in `BOSS_LIST` for all TBC bosses we ship.
### How it manifests post-fix
| Scenario | Pre-1.3.12 | Post-1.3.12 |
|----------|-----------|-------------|
| Boss A, COMBAT_LOG fires first | Override applied | Override applied (same) |
| Boss A, ENCOUNTER_END fires first | **Default fallback** | Override applied via name lookup |
| Multi-NPC encounter (Eredar Twins) | Depends on signal order | Override applied to whichever NPC has it set |
| Boss with no override set | Default applied | Default applied (same) |
### Version
- Bumped from **1.3.11** to **1.3.12** — per-boss EP override now reliably applies regardless of which signal source detected the kill.
---
## 1.3.11 — May 17, 2026
### Bug Fix — Boss Fight Duration Showing 30+ Minutes
**Root cause.** The raid detail popup's `DURATION` column was computing `enc.time - prevEncounterTime` — which is the **gap between kills**, not the boss fight duration. If you wiped a few times, took a 25-minute repair / strategy break, then killed the next boss in 4 minutes, the boss kill displayed as "29:00 duration" because the math counted the entire pre-kill downtime.
**Fix.** Encounters now track the ACTUAL engagement window:
1. `OnEncounterStart` records `{id, name, startTime}` on a module-level `_activeEncounter` slot.
2. `OnEncounterEnd` computes `duration = time() - startTime` and clears the slot.
3. `OnBossKilled` / `OnBossWipe` accept the engageTime + duration and store both on the kill record.
4. RAID/KILL broadcast carries the new fields (payload slots 8 + 9). Backward-compat: older receivers read slots 1-7 and ignore the extras silently. Older senders don't include the fields, and receivers fall back to the legacy "time since previous kill" display for those legacy records.
5. The detail popup's `DURATION` column now displays the actual fight length: `3:42` for a 3-minute-42-second fight regardless of how much downtime preceded it.
**COMBAT_LOG fallback path.** Clients where `ENCOUNTER_END` is unreliable use `COMBAT_LOG_EVENT_UNFILTERED` (UNIT_DIED) to detect kills. That path still works: `OnBossKilled` consults `_activeEncounter` to derive engageTime if the caller didn't pass it explicitly.
### Bug Fix — Accidental Pulls Logging as Wipes
**Root cause.** A brief encounter (rezz aggro, mistarget that resets in seconds) was logged in the raid record as a Wipe entry, even though no real attempt was made. These polluted the encounter list with sub-minute wipe rows.
**Fix.** New setting `minWipeDurationSec` (default **60 seconds**) gates whether ENCOUNTER_END(success=0) actually records a wipe. If the engagement was shorter than the threshold, the wipe is silently dropped from the raid record. The leader sees a one-line chat notice so they know a brief pull was caught and discarded:
> [VibeCheck] Brief encounter (12s) not logged — Brutallus
**Kills are NEVER filtered** by this threshold. An overgeared 30-second clean kill is still a valid kill — the filter only applies to failed attempts (`success=0`).
### How the two fixes interact
- 12-second rezz aggro → no log entry (filtered by minWipeDurationSec).
- 4-minute wipe on Felmyst → logged with `duration = 240`, displays as "4:00".
- 25-minute lull, then 3-minute kill on Brutallus → logged with `duration = 180`, displays as "3:00" (not "28:00" like before).
- Legacy raid records that pre-date this version don't have `duration` fields → fall back to the old "time since previous kill" math, so existing logs render the same as before.
### Version
- Bumped from **1.3.10** to **1.3.11** — boss fight duration + accidental pull filter.
---
## 1.3.10 — May 17, 2026
### Feature — One-Click Reassign from View Rolls
The v1.3.09 reassign flow required two trips through the right-click menu (View Rolls to identify the next-highest, then Reassign Winner to type the name). v1.3.10 collapses it into a single click: click any roller in the View Rolls dialog and confirm.
**Behavior:**
- **Officer rows** (clickable, not the current winner) — hover shows a tooltip explaining the action; click pops a StaticPopup confirming the GP refund and re-charge; accept performs the swap.
- **Current winner row** — hover tooltip explains "already holds this item"; no click action.
- **Non-officer view** — rows are not clickable. The dialog stays useful for raiders who want to verify their own rolls.
The dialog's sub-header now hints at the new behavior for officers: `Click a roller to reassign to them.`
**Internals.** Extracted the reassign logic from `OpenReassignWinnerDialog` into a shared `UI:ReassignLootWinner(entry, newWinner)` helper that handles role-gating, history-entry relocation by content match (in case sync inserted entries above it since the right-click), GP refund + charge, and both sync broadcasts (`HIST_DEL` for the refund, `DELTA` for the new charge). The typed-name dialog from 1.3.09 now delegates to the same helper, so the two paths are guaranteed to behave identically.
The legacy `Reassign winner...` right-click action still exists for the rare case where the new owner didn't appear in the roll list (e.g., gifting outside the roll system, or a manually-awarded item).
### Workflow comparison
**Before (1.3.09):**
1. Right-click GP entry → View rolls → identify next-highest roller (close dialog)
2. Right-click GP entry → Reassign winner... → type the name → Reassign
**Now (1.3.10):**
1. Right-click GP entry → View rolls → click the next-highest roller → Reassign
### Version
- Bumped from **1.3.09** to **1.3.10** — one-click reassign from View Rolls.
---
## 1.3.09 — May 17, 2026
### Feature — Raid Logging Quality of Life (5 in 1)
Major pass on post-raid bookkeeping based on raid-day feedback.
#### 1. End raid from any officer's client
Previously the **Force End Now** button only showed for "stale" raids (more than 12 h old). That meant if the raid leader walked away without killing the final boss, no other officer could finalize the raid record until the 12-hour staleness timer fired. Now any officer can end any in-progress raid directly from the raid detail popup:
- **Stale raid** (>12 h, no kill events recently): button reads `Force End Now` (existing flow — sets `end = lastKill + 30 min` and flags `autoHealed`)
- **In-progress raid** (<12 h, just left mid-clear): button reads `End Raid Now` — sets `end = time()` and broadcasts RAID/END to all peers
Button is officer-gated. Single click, no confirm — the raid is already in a "needs cleanup" state.
#### 2. Attendance tab in raid detail popup
The raid detail popup now has two tabs:
- **Encounters** (default) — existing kill / wipe / loot list
- **Attendance** — two-column view of **Attendees** (raiders who were in the snapshot) and **Bench** (declared standbys). Class-colored, alphabetized. Counts in each column header.
Tab state is intra-dialog — opening a different raid always resets to Encounters so the experience is predictable.
#### 3. Loot attribution rewrite
Reports of "I don't see the loot we distributed" had a real cause. The old algorithm used time windows (`[killTime - 30s, nextKillTime + 30s]`) and silently dropped loot that fell outside any window — late master-loot trades, items awarded after raid end, or AFK officer trades all slipped through.
New algorithm:
1. Every GP history entry whose `raidId` matches this raid is attributed somewhere.
2. Each loot row binds to the most recent kill encounter that happened before its timestamp (with 30 s slack for clock skew).
3. Loot timestamped before every kill (rare — pre-pull mistake) binds to the first kill.
4. Loot belonging to a raid with no kill events at all surfaces in a new **Unattached loot** section at the top of the detail popup, so the data is visible even if the encounter context is missing.
End result: **every loot row tagged with this raidId is visible somewhere**. No more silent drops.
#### 4. Right-click → Reassign Winner
When a winner backs out of an item AFTER you've already pressed Award, you used to have to manually delete the GP charge and re-award through the UI workflow. Now: right-click the GP entry in the EP/GP log, click **Reassign winner...**, type the new player's name, click Reassign.
The dialog:
- Confirms the current holder + GP cost
- Live-previews the new winner's current GP / PR as you type
- One click reassigns: refunds the old winner via `DeleteHistoryEntry` (which broadcasts `HIST_DEL` for sync convergence), charges the new winner with the same GP and item link, and broadcasts a fresh `DELTA` for the new charge
- The new history entry's `reason` field reads `"reassigned from <oldName>"` so the audit trail is clear
Both halves go through the existing sync code paths so peer convergence is guaranteed (online: live; offline: on next FULL).
#### 5. Roll history capture + right-click → View Rolls
`Loot:Award` now captures the full roll leaderboard at the moment of award and stores it on the GP history entry (new `rolls` field — list of `{name, type, value}`). The roll snapshot also propagates via DELTA broadcast (new 9th payload field, backward-compat: older clients ignore it), so every officer's client has the same data.
Right-click any GP loot entry → **View rolls (N)** to see the dialog. Shows every roll sorted by priority (MS > MINOR > OS, then by value within each type). Columns: RANK, NAME, TYPE, ROLL, current PR. The original winner is highlighted in green.
Pairs naturally with **Reassign winner...** — you see who had the next-highest roll, then reassign to them with one click.
### Protocol Note
The DELTA payload gains a 9th tab-separated field for the encoded roll snapshot (`name|type|value;name|type|value;...`). Pre-1.3.09 clients only read fields 1-8 and silently ignore the extra field — fully backward-compatible. Peers running 1.3.09+ get the rolls; peers on older versions just don't show the View Rolls option.
### Version
- Bumped from **1.3.08** to **1.3.09** — raid logging quality-of-life pass.
---
## 1.3.08 — May 13, 2026
### Feature — Addon Version Check (Officer Tool + Self-Notification)
Two ways for outdated installs to surface, so officers can keep the raid on the same page without manually pinging everyone in Discord.
#### Self-notification
When the user receives any addon message from a peer running a **newer** version than theirs, a one-time chat warning fires:
```
[VibeCheck] Your VibeCheck is out of date. You: 1.3.05 | Latest seen: 1.3.08 from Thrall. Update via CurseForge.
```
- **One per session.** A boolean flag prevents the warning from firing again for any subsequent peers in the same session — no spam if 20 people are running newer.
- **Detection is free.** Every CHAT_MSG_ADDON the addon already processes carries the sender's version string in the framing header. The check is a single string compare per inbound message, gated by `_notifiedNewerVersion` to bail immediately after the first hit.
- **Logged to Sync Debug** so an officer can see who triggered the notification: `INFO outdated notify: ours=1.3.05 peer=1.3.08 (from Thrall)`.
#### Officer dialog — Check Addon Versions
New button at **Settings → Officer Settings → Mass Edit → Addon Health → Check Addon Versions**. Opens a 560×440 popup listing every peer we've heard from with:
| Column | Detail |
|--------|--------|
| **PLAYER** | Short name (no realm suffix) |
| **VERSION** | Semver-like string from the framing header |
| **STATUS** | Color-coded comparison vs your version |
| **LAST SEEN** | "just now" / "Nm ago" / "Nh ago" |
Status badges:
- |cff33ff99**up to date**|r (green) — exact match
- |cff77b3ff**newer than you**|r (blue) — peer is ahead, **you** should update
- |cffd9a64d**out of date**|r (yellow) — peer is one or more patch versions behind
- |cffee4d4d**VERY OUTDATED**|r (red) — peer is a major or minor version behind (e.g. they're on 1.2.x while we're on 1.3.x)
Rows are sorted **out-of-date first**, then alphabetically — so the people you need to ping are at the top.
**Refresh button** broadcasts a fresh `Heartbeat(true)` and re-opens the dialog after 1.5 s, by which time peers' replies have populated the presence cache. Useful for catching raiders who logged in after you first opened the dialog.
#### New helper — `Sync.CompareVersion(a, b)`
Public version-compare function on the Sync module. Splits each string on any non-digit run (so `"1.3.07-beta"` and `"1.3.07"` both parse to `{1, 3, 7}`) and returns negative / 0 / positive standard 3-way compare result. Used by both the dialog and the self-notification path; available to any other module that wants version-aware logic later.
### Version
- Bumped from **1.3.07** to **1.3.08** — addon version check dialog + outdated self-notification.
---
## 1.3.07 — May 13, 2026
### Bug Fix — Roll Window Timer Wrong on Receivers (Clock Skew)
Player reported their roll popup showed `Rolls close in 2 minutes` when the leader had `rollDurationSec` set to 30 seconds. Traced to a clock-skew bug in how the receiver computed the countdown.
**Root cause.** WoW's `time()` function returns the player's **local OS clock**, not a server-synced timestamp. Two players whose machine clocks are set 90 seconds apart will see two different values for "now" — and most players don't have NTP locked, so multi-minute drift is common.
The ROLL_START broadcast carried both `duration` (e.g. 30) and `endTime` (the leader's absolute `time()` + duration, e.g. `1700000030`). The receiver used `endTime` directly to compute the countdown:
```
remaining = endTime - time()
```
Concrete example: leader's OS clock = 1000, so `endTime = 1030`. Network message arrives at a receiver whose OS clock = 910. Receiver computes `remaining = 1030 - 910 = 120 seconds` and renders "Rolls close in 120s." Exact symptom reported.
**Fix.** The receiver now **ignores the leader's `endTime`** and recomputes from `duration` using the local clock:
```
endTime = time() + duration -- always, using receiver's clock
```
Each client now sees exactly `duration` seconds on its own timer regardless of clock skew. The trade-off is ~1 second of extra grace (the message took some time to arrive between leader broadcast and receiver compute), which is dramatically better than 90 seconds of skew. The auto-close fallback timer that fires `duration + 30 s` after receipt also benefits — it now correctly closes a stale popup 30 s after the leader's intended end-of-roll, instead of 30 s after some clock-skew-adjusted moment.
**Why this likely caused the missing-popup reports too.** A receiver whose clock is FASTER than the leader's would compute `remaining = endTime - time()` and get a **negative** number — `time()` was already past the leader's `endTime` at receipt. The OpenRollWindow path showed the frame but the timer immediately displayed "Rolls closed — pick a winner" and the auto-close fallback (`secsLeft + 30 s`) became effectively 30 s, after which the window disappeared. To the player, that looks identical to "popup never opened."
The protocol still carries `endTime` in the payload for backward compatibility (older receivers still read it) and for the Sync Debug log line, but it's no longer trusted for timer math.
**Pipeline audit (nothing else affected).** Other timing displays already use receiver-local clocks consistently:
- Raid "in progress" duration: `time() - raid.start`, both local → correct
- "Last sync N minutes ago": `time()` minus a value stored when the FULL was applied → both local
- Heartbeat freshness checks: all local-clock vs local-clock arithmetic
- Roll auto-close fallback: now correct after this fix
- Roll history timestamps: stored at the moment of award using local clock → consistent
### Version
- Bumped from **1.3.06** to **1.3.07** — roll timer clock-skew fix.
---
## 1.3.06 — May 13, 2026
### Stability — Connection / DC Audit
Targeted pass on three classic addon disconnect / lag-spike vectors. None of these were definitely causing live problems but each had a measurable risk under raid-day load — closed all three before they could matter.
#### Fix 1 (HIGH) — Heartbeat debounce on rapid `GROUP_ROSTER_UPDATE`
**The risk:** during raid setup, `GROUP_ROSTER_UPDATE` fires 5-10 times in quick succession as members join, leave, swap roles, and toggle online status. The handler scheduled a force-Heartbeat with a random 0-3 s stagger on **every fire**, with no debounce. Math: 25 raiders × 10 events × 1 scheduled HB each = up to **250 HBs landing on the raid channel in a 3-5 s window**. Per-client send rate stays low (~3 msgs/sec from any single client, fine), but the *aggregate* channel throughput averages 50-80 msgs/sec — well into WoW's anti-flood disconnect territory.
**Fix:** new `Sync:ScheduleHeartbeat()` helper that holds a `_hbPending` flag. First call schedules a randomly-staggered HB; subsequent calls during the pending window are ignored. `Core.lua` GROUP_ROSTER_UPDATE handler now uses this instead of calling `C_Timer.After(rand, ...)` directly. Effect: regardless of how many roster updates fire, **each client sends at most one HB per pending window**, which collapses the worst-case channel burst from 250 messages to 25.
#### Fix 2 (MEDIUM) — `npcIdFromGUID` allocation in the combat-log hot path
**The risk:** `Attendance:OnCombatLog` fires for every COMBAT_LOG_EVENT_UNFILTERED — hundreds of times per second in combat. The early-exit on non-UNIT_DIED is fast (one string compare and return), but UNIT_DIED itself fires 10-50/sec during a chaotic pull (raid kills + trash + pet deaths + totems). For each UNIT_DIED we extracted the NPC ID from the GUID using `select(6, strsplit("-", guid))` — which allocates a 7-element table just to grab one number.
**Fix:** replaced with `guid:match("^[^-]*-[^-]*-[^-]*-[^-]*-[^-]*-(%d+)-")` — a single regex capture that returns just the integer, zero intermediate allocations. ~5x faster and **zero GC churn** in the addon's hottest hot path.
#### Fix 3 (LOW) — Idle ticker burning per-frame closures
**The risk:** `Sync.lua`'s `_recentAddonWhispers` prune frame used an `OnUpdate` handler that ran on every frame (~60/sec at idle) just to accumulate elapsed time and bail until 60 seconds had passed. Per-frame cost is tiny but it's pure waste — the work only needs to happen once per minute.
**Fix:** replaced with `C_Timer.NewTicker(60, fn)`. Schedules exactly one callback per minute, **zero per-frame work**. Net delta is ~60 fewer Lua closure calls per second forever.
#### What checked out clean (no fix needed)
- `BATCH_EP` for boss kills is already one message per kill (not 25)
- `Sync:Send` correctly routes URGENT messages immediately and queues everything else at 20/sec via the shared chunk ticker
- `ROSTER_DEL` from Prune Ex-Guildies goes through the rate-limited queue (~20/sec drain, safe even at 50+ prunes)
- `pruneStaleEntries` is bounded to the active presence / req-cooldown / inbox sets (~25-50 entries max)
- `PLAYER_EQUIPMENT_CHANGED` profile rebroadcasts have internal 2 s debounce + 15 s cooldown
- All OnUpdate handlers other than Fix #3 are either user-input-gated (drag handlers fire only while mouse held), throttled to 1 Hz (footer, roll timer, raid row pulse), or fire only when the parent frame is shown (pip pulse)
- Self-echo guard in `Sync:OnMessage` prevents the leader from processing their own broadcasts
- The CHUNK_INTERVAL / CHUNK_SIZE math for large FULL sends is correct (50 ms × 20/sec × 215 bytes/chunk, well under WoW's per-client outgoing quota)
### Version
- Bumped from **1.3.05** to **1.3.06** — connection / DC stability audit.
---
## 1.3.05 — May 13, 2026
### Bug Fix — Roll Popup Reliability (Cold-Start Authority Race)
Audited the full roll-popup pipeline after reports of players occasionally not seeing the roll window. Found and closed a real cold-start race that explains the symptom even in fully-up-to-date addons.
**The race:**
1. Player reloads or just logs in
2. `CHAT_MSG_ADDON` listener is wired immediately (event dispatcher fires during ADDON_LOADED)
3. But `_rankCache` (guild roster) takes ~5-10 s to populate — the server hasn't pushed the roster yet
4. Raid roster takes another ~2-3 s — `UnitName("raid1..N")` returns nil even though `GetNumGroupMembers()` reports the right count
5. A `ROLL_START` arriving during this window hits `senderIsLeaderOrOfficer(sender)`
6. `senderIsOfficer` returns false (rank cache empty, no fallback at this layer)
7. `senderIsGroupLeader` returns false (raid roster lookup also empty)
8. **Message silently dropped — popup never opens for the rest of the roll**
**Fix — asymmetric-cost auth (`rollAuthOk`):** new helper that runs the standard officer-or-leader check, then if it fails, decides whether we *actually had data* to make the call. If both the rank cache AND the raid roster were empty, we couldn't have verified anyone — accept the message instead of dropping it. If either cache had data and the sender still failed, that's a real rejection (someone genuinely doesn't have authority).
The rationale is asymmetric cost:
- **False positive** (accepting a spoofed ROLL_START during cold-start): user sees an extra roll popup and clicks Pass. Annoying. Recoverable.
- **False negative** (rejecting a legit ROLL_START during cold-start): user misses the roll entirely. No recovery — by the time they realize, the timer's expired.
The first is strictly less bad than the second, so when in doubt, accept.
Same fix applied to `ROLL_END` so post-cold-start FOMO doesn't leave a popup orphaned.
### Defensive Instrumentation
Sprinkled the receive path with Sync Debug logging so the *next* time someone complains about a missing popup, we can pinpoint exactly where it dropped:
- **ROLL_START accepted under cold-start fallback** logs `[cold-start accept]` in the IN line so you can tell the new code path from the strict path
- **Empty `itemLink`** in OnRollStartBroadcast: `OnRollStartBroadcast: empty itemLink (popup not opened)`
- **Malformed `itemLink`** missing the `|Hitem:` prefix: `OnRollStartBroadcast: malformed itemLink (popup not opened): <first 40 chars>`
- **UI not yet initialized**: `OnRollStartBroadcast: ns.UI.OpenRollWindow missing`
- **Successful popup open**: `roll popup opened: <item name>` (INFO line — only present when Sync Debug is recording)
Officers can now open **Settings → Sync & Data → Start Sync Debug** before raid to capture every roll's path in real time. If a player reports missing the popup, the officer's log shows exactly what their client saw.
### Audit Notes (no code change required)
For the record, the rest of the pipeline checks out:
- `Sync:Send` correctly drops if **sender's** `syncEnabled` is off, with a one-time chat warning to the leader (Loot.lua:213)
- `Sync:OnMessage` does NOT check the **receiver's** `syncEnabled` — so a sync-disabled user still receives the ROLL_START and opens the popup (correct — popups should always work even if you've opted out of broader sync traffic)
- `ROLL_START` is in `SEND_URGENT`, bypassing the chunk queue and sending immediately via `SendAddon` (no throttle delay)
- Self-echo guard correctly skips processing our own broadcasts (the leader already has the session live locally)
- v1.2.7's session-teardown fix is still in place: a stale `L.session` no longer swallows incoming ROLL_STARTs
- Roll frame is strata DIALOG with `SetClampedToScreen(true)`, so it can't be hidden off-screen or behind other UI
### Version
- Bumped from **1.3.04** to **1.3.05** — roll popup cold-start fix + defensive logging.
---
## 1.3.04 — May 13, 2026
### Bug Fix — Prune Ex-Guildies Now Propagates to Everyone
**Root cause:** the `ROSTER_DEL` sync message added in 1.2.15 was the **primary** propagation path, but it had no backstop. Peers who were offline at the moment of the prune, peers whose addon-message chunk got dropped, peers who freshly installed the addon, or peers who took longer than 90 s to come online after the broadcast — none of them ever received the deletion. The "belt-and-braces" SendFull that follows the prune also didn't help: the same-season FULL roster merge is **purely additive** on receive — receivers iterate the incoming roster and never remove locally-known names that are absent from it. So a peer who missed the live ROSTER_DEL kept the pruned player forever.
**Fix — roster-deletion tombstones**, parallel to the existing history-tombstone system:
1. **`DB:RemovePlayer(name)`** now writes the deletion to `db.deletedRosterNames[name] = time()`.
2. **`DB:GetOrCreatePlayer(name)`** clears the tombstone when a new roster row is created for that name — handles the "officer pruned Foo, then Foo rejoined the guild and shows up in raid" case so they're not silently re-deleted on the next FULL.
3. **`Sync:SendFull`** serializes the tombstones into a new `---ROSTERTOMB---` payload section.
4. **FULL receive** parses the new section. For each tombstoned name, if the local roster has them but no activity newer than the tombstone (`lastSeen` and history both predate it), drop the row. If activity exists post-tombstone, the tombstone is treated as obsolete and cleared locally (the player legitimately came back).
5. **ROSTER_DEL receive handler** also writes the tombstone locally — even when the player isn't in the receiver's roster — so the receiver's **own future FULLs** carry the deletion forward to peers who weren't online when the original officer pruned.
6. **Tombstone TTL** matches the existing 90-day expiry for history tombstones, so the set can't grow without bound.
**Backward compat.** Pre-1.3.04 clients don't know about the `---ROSTERTOMB---` header and will treat its lines as if they belonged to the history-tombstones section. Those parse cleanly as malformed history tombstones (the roster-name keys never match a `histKey` so they're inert), then age out after 90 days. Harmless, just slightly leaky during the transition window.
**Visibility.** Every roster-tombstone application is logged as an INFO line in the Sync Debug recorder:
- `FULL ROSTERTOMB: dropped Foo` — tombstone was applied, row removed
- `FULL ROSTERTOMB: Foo is active after tomb, dropping tombstone` — player came back, tombstone obsolete
So a confused officer can open Sync Debug to verify what's actually happening on each FULL receive.
**Net effect:** an officer who runs Prune Ex-Guildies now triggers:
- Primary: per-name ROSTER_DEL broadcasts (immediate, as before)
- Backstop 1: SendFull 0.5 s later with the new ROSTERTOMB section (catches peers who missed the live broadcast)
- Backstop 2: every receiving peer adds the tombstone to their own DB, so their next outbound FULL also carries it (gossip propagation — eventually reaches every officer in the guild)
### Version
- Bumped from **1.3.03** to **1.3.04** — Prune Ex-Guildies sync convergence fix.
---
## 1.3.03 — May 13, 2026
### Feature — Hide Pugs from Standings
Common ask for guilds that fill empty raid slots with players outside the guild: keep those pugs out of the EPGP standings while still letting them roll on and receive loot. New setting **Track guild members only**, defaults ON, lives at **Settings → Officer Settings → Mass Edit → Roster Scope**.
**Three things change when the setting is on:**
1. **Raid snapshot ignores pugs.** `Attendance:SnapshotRaid` checks each raid member against the live guild roster; non-guildies are silently skipped. They don't get a roster row created, they aren't listed as attendees, and they don't earn raid-start / boss-kill / streak EP. Each skip is logged as an INFO line in the Sync Debug recorder (`snapshot skipped non-guild: <name>`) so an officer can verify the filter is doing the right thing.
2. **Standings tab filters them out at render time.** Any roster record whose name isn't currently in the guild is hidden from the Standings list. This is the defensive catch for legacy data — pugs that slipped into the DB before this version, or guildies who have since left. The result count line shows the hidden count (e.g. `25 players (3 pugs hidden)`) so you can see the filter is active.
3. **Loot still works for pugs.** The roll system and loot-award flow run through `Loot.lua`, which creates a roster row on demand when GP is charged. So a pug can `/roll`, win, and receive the item with GP charged — they just don't appear in Standings afterward. If you ever want to invite the pug into the guild later, their GP history is preserved in the EP/GP log.
**Cold-start safety.** The first 5-10 seconds after login, the server hasn't sent the guild roster yet, so `GetNumGuildMembers()` returns 0. If we naively filtered, the entire raid would be silently excluded during that window. The new `ns.isGuildMember(name)` helper handles this: when the roster cache is empty, it returns `true` (allow everyone) until the cache populates. Better to over-track briefly than to lock out the whole raid.
**Architecture note.** The guild-membership check is exposed as `ns.isGuildMember(name)` on top of Sync.lua's existing rank cache, so it's essentially free — the cache is already maintained for sender-authority checks. The new feature adds zero new server queries.
**Cleaning up existing pug data.** Three options:
- Leave them in the DB — the Standings filter hides them with zero ongoing impact.
- Hit **Settings → Officer Settings → Mass Edit → Prune Ex-Guildies** for a permanent cleanup (this was already there; now it pairs nicely with the new toggle).
- Turn the setting off temporarily if you ever DO want to see pugs in Standings.
### Version
- Bumped from **1.3.02** to **1.3.03** — guild-only roster filter.
---
## 1.3.02 — May 13, 2026
### Feature — Raid Auto-End Reliability
Closes the biggest gap in raid tracking: raids that never auto-ended because the group left without killing the final boss. Three new auto-end triggers, plus a stale-raid heal pass, plus UI affordances for cleaning up the damage on existing zombie raids.
**Previous behavior:** the addon only auto-ended a raid when the **final boss of the zone** was killed (5-min delayed close) or on clean `PLAYER_LOGOUT`. Anything else — wipe on the last boss, disband mid-clear, ALT+F4, raid-leader role swap — left the raid "active" forever, polluting the log and making everything claim to be "In progress."
**New auto-end triggers (Attendance.lua + Core.lua):**
1. **Leave-instance timer** — listens to `ZONE_CHANGED_NEW_AREA`. When the raid leader steps out of the raid instance, a 10-minute timer starts. Re-entering cancels it. If it fires, the raid is closed at the current time. Friendly chat messages: `"Raid auto-end scheduled in 10 min (left raid instance)"` → on cancel `"Auto-end cancelled (re-entered raid instance)"` → on fire `"Auto-ending raid (left raid instance)"`.
2. **Disband timer** — listens to `GROUP_ROSTER_UPDATE`. When the raid leader's group goes from raid → solo (disbanded or left), a 5-minute timer starts. Re-joining a raid cancels it.
3. **Stale-raid heal on login** — listens to `PLAYER_ENTERING_WORLD` (fires on `/reload` too, unlike the previous `PLAYER_LOGIN`-only fallback). Any "active" raid with `start` more than 12 hours ago is auto-closed. The healed `end` is set to `lastKillTime + 30 min` (or `start + 5 h` if no kills exist), so duration and loot-attribution windows stay bounded. The raid record is tagged `autoHealed = true` so the UI can label it visibly. `RAID/END` is broadcast to peers so everyone converges on the same healed timestamp.
**Single-leader guard:** only the actual `UnitIsGroupLeader("player")` client runs the timers. Without this, every officer's client would race to call `EndRaid`, and the slowest broadcast would re-stomp the already-ended raid.
### Bug Fix — Time and Duration Display
Several display quirks that contributed to "this doesn't make sense":
- **Stale-raid badge in main raid list.** A never-ended raid older than 12 h now shows `"Stale - click to end"` in red instead of a pulsing green `">> In progress"` that would otherwise lie indefinitely. Shift+click on a stale row in the main list force-ends it immediately. Tooltip explains.
- **Force End Now button** in the raid detail popup, visible only for stale raids and only to officers. Single-click (no confirm — the raid is already broken; the action is purely corrective). Sets `end = lastKill + 30 min` and broadcasts `RAID/END` to peers.
- **Detail popup duration math.** A stale raid's duration is now capped at last-kill time instead of growing forever via `time() - raidStart`. End-time line displays as `"Stale - never ended"` (red) for stale raids, `"In progress"` (green) for fresh active raids, or `"<date> (auto-healed)"` (orange) for raids that were closed by the stale-heal pass.
- **Negative duration clamp.** Per-encounter `TIME` and `DURATION` columns now floor at 0 when kill timestamps arrive out-of-order (rare, e.g. an assist's COMBAT_LOG fires before the leader's ENCOUNTER_END due to clock skew between peers). Previously displayed as `"-12:34"`.
### Feature — Loot Distribution in Raid Log
The data was always tracked (every loot award has its `raidId` set), but the detail popup's `LOOT` column header was never populated and there was no loot count anywhere else, making it look like loot wasn't recorded. Rebuilt the loot display:
- **Main raid list** gains a `LOOT` column showing the total items distributed for that raid at a glance (e.g. `12`). Pre-bucketed in one pass through history before rendering so it's free per-row.
- **Detail popup encounter row** now shows the per-kill item count in the `LOOT` column (e.g. `3 items`). The header is no longer empty.
- **Per-item indented sub-rows** still appear under each kill showing winner, GP charged, and item link. Hovering a sub-row now shows the full item tooltip via `GameTooltip:SetHyperlink`.
- **Wider matching window for the last kill of a raid.** Master-loot trades that happen after the raid is officially over (especially common with auto-trade) were getting dropped from the last-boss attribution. Trailing window for the final encounter is now `raidEnd + 20 min` (or `lastKill + 30 min` if the raid never ended), so post-clear loot awards still attach to the boss that dropped them.
### Version
- Bumped from **1.3.01** to **1.3.02** — raid tracking reliability + loot attribution rebuild.
---
## 1.3.01 — May 13, 2026
### Bug Fix — "No player named 'X' is currently playing" chat spam
When a peer with the addon logged off or came online, the chat window would sometimes get spammed with `No player named 'X' is currently playing.` system messages. **Root cause:** the addon's heartbeat / rev-mismatch reply paths use `SendAddonMessage("WHISPER", target)` to talk to specific peers. WoW's server bounces these against offline players the same way it bounces a `/whisper` — and the bounce-back surfaces as a CHAT_MSG_SYSTEM error in chat, even though the underlying addon message itself was invisible. With a 30 s presence cache, there's a window of several seconds where the addon still thinks a peer is online and tries to whisper them right as they log out.
**Fix:** the sync layer now installs a `CHAT_MSG_SYSTEM` chat filter that suppresses these offline-errors only when they correspond to one of our own recent addon-message whisper targets. Every time `SendAddon(... "WHISPER", target ...)` fires, we record `target` with a timestamp; any "no player named X" message that arrives within 10 s and matches a recorded target is dropped from the chat window. The suppression table self-prunes every 60 s so it can't grow without bound.
**What's still preserved:** real `/whisper` errors (Loot.lua winner notifications, Whisper.lua replies — actual `SendChatMessage` calls, not addon messages) are not affected. If you try to whisper a loot winner who's offline, you still see the error — because that one's actionable (the loot announcement didn't reach them).
**Localization-safe:** the filter uses WoW's `ERR_CHAT_PLAYER_NOT_FOUND_S` global as the format-string template, with Lua pattern specials escaped, so it works on any client locale.
**Bonus:** every suppression is logged as an `INFO` line in the Sync Debug recorder so an officer can confirm the filter is doing its job — look for entries like `suppressed offline-error for Foo (recent addon-msg WHISPER)`.
### Version
- Bumped from **1.3.00** to **1.3.01** — offline-whisper chat spam suppression.
---
## 1.3.00 — May 13, 2026
**Milestone release.** Rolls up every change developed across the 1.2.12 → 1.2.18 patch series into a single named version for CurseForge. The detailed per-patch entries are preserved below for traceability — this header is the at-a-glance summary.
### Headline Features
- **Sync Debug recorder.** New live activity recorder accessible from **Settings → Sync & Data → Troubleshooting → Start Sync Debug**. A 720×460 popup surfaces every outgoing send, every accepted incoming message, every dropped message **with the exact reject reason**, and every error in real time. Filter buttons, pause/resume, clear, copy-to-clipboard, color-coded categories. Zero overhead when disabled (single nil-check + early return per call).
- **Prune Ex-Guildies.** New officer action under **Settings → Officer Settings → Mass Edit → Roster Maintenance**. Compares the live guild roster against the EPGP roster, shows a preview dialog (class-colored names, EP/GP, last-seen date), double-confirms before removal. New `ROSTER_DEL` sync message type propagates removals across all officer clients. Preserves alts whose main is in-guild, the local player, and history rows.
- **Footer sync indicator reworked.** Five distinct states (off / syncing / no peers / behind N / in sync) instead of three. Live updates on a 1 Hz ticker. Hover tooltip shows full diagnostic picture (rev, peer rev, peer count, last sync time + sender). Click anywhere on the pill to pull latest from best peer.
### Sync & EPGP Hardening
- **`BATCH_EP` self-contained** — fixes "always one boss behind" sync lag where the loot master's points trailed by one boss kill.
- **`/vcl sync` works with non-officer peers** — 90 s explicit-request window allows targeted FULL acceptance from any peer.
- **Raid log fixes** — durations no longer show 472,000 hours (raidId-prefix heal + 24 h sanity cap), zones no longer stick on "Unknown" (RAID/START backfills stub metadata in place, Initialize heal rewrites missing zones from BOSS_LIST).
- **Tier token GP** now uses the redeemed item's ilvl/slot instead of the token's, so T4/T5/T6/SWP tokens cost the right GP.
- **Eredar Twins triple-credit** fixed — NPC_TO_ENCOUNTER multi-NPC dedup collapses peer NPCs + ENCOUNTER_END to a single bucket.
- **Authority gates tightened** — `/vcl bench add|remove` now Raid Manager only; `/vcl import` and all `/vcl test*` commands now officer only; `/vcl help` annotates restrictions with `[O]` and `[R]` markers.
### UX Polish
- **Settings → Sync & Data reworked** into Pull / Send / Officer Push / Status / Troubleshooting sections with clearer button names and inline hints.
- **Profile data decoupled** from EPGP via the separate `VCLootProfilesDB` SavedVariable — gear/wishlist/specs now survive `/vcl reset`.
- **Self-test harness** (`/vcl test`) — runs assertions against the live addon, verifying sync correctness, EPGP math, decay, GP overrides, etc. Auto-cleanup so the live DB is untouched.
- **Build tooling** — `tools/build_release.py` parses the TOC for the file list (no more hardcoded manifest), supports `--install` to copy directly into Interface\AddOns for one-step testing.
### Critical Bug Fixes
- **UI.lua load-time parse error** — `self:Method and 0` was a Lua syntax error that killed the entire UI.lua chunk at addon load. Fixed in 1.2.16.
- **Roll popup not showing for round 2+** — leader-session preservation branch was blocking the loot master's client. Fixed in 1.2.7.
- **Missing-glyph boxes** in Sync Debug popup — replaced Unicode `●` / `○` / `→` with ASCII-safe equivalents (`[REC]`, `>>`) since WoW's Friz Quadrata font lacks them.
### Version
- Bumped from **1.2.18** to **1.3.00** — milestone release for CurseForge.
---
## 1.2.18 — May 13, 2026
### Authority Gates — Tighter Slash Command Restrictions
Closed two classes of slash-command authority gaps and added an at-a-glance authority legend to `/vcl help`.
**`/vcl bench add|remove` is now Raid Manager only.** Previously had no slash-layer check AND no check in `Attendance:BenchAdd` / `Attendance:BenchRemove` themselves — a regular guild member could mutate the local raid roster, which would visibly show a phantom bench change before failing silently when the broadcast was rejected by peers (receivers gate on sender = raid leader). The check now lives at the `Attendance` layer (`canManageRaid()` — same gate as `/vcl raid start|end`), so the lock applies everywhere `BenchAdd`/`BenchRemove` is called from, not just the slash path. Non-managers see the role error immediately instead of being confused by a local-only change.
**Five commands moved to officer-only:**
- `/vcl import` — the dialog's Import button was already officer-checked, but a non-officer could still open it, paste data, and preview parses. Now the slash itself refuses unless you're an officer.
- `/vcl test` — runs the self-test harness, which mutates the local DB (creates synthetic players, awards EP, runs decay) before rolling back. Now officer-only so a non-officer doesn't see transient test rows or get confused by mid-run output.
- `/vcl test info` / `/vcl test peers` / `/vcl test gp <link>` — diagnostic prints (dbRev, peer presence, item GP breakdown). Same officer lock for consistency — this is internal sync / DB state that's officer-grade information.
**`/vcl help` now shows authority markers.** Each command line that has a restriction is suffixed with `[O]` (officer-only, gold) or `[R]` (raid manager, blue), with a legend at the top of the dump:
```
Legend: [O] = officer only [R] = officer or raid leader
/vcl - toggle main window
/vcl raid start [zone] - start tracking a raid [R]
/vcl award <player|all> <EP> [reason] [O]
...
```
No more guessing why a command silently failed — the marker tells you up front whether you have permission.
### Authority Reference (post-1.2.18)
For clarity, the three tiers in play:
| Tier | Who passes | Commands |
|------|-----------|----------|
| **Officer** | Guild Leader, Officers, Officer Alts, Loot Master | `award`, `gp`, `alt`, `decay`, `import`, `backup`, `restore`, `reset`, `test*` |
| **Raid Manager** | Officer **OR** raid leader | `raid start`, `raid end`, `bench add`, `bench remove` |
| **Loot Runner** | Officer **OR** raid leader **OR** Loot Master | `roll` |
| **Open** | Anyone | `show`, `standings`, `config`, `sync`, `cancelaward` |
### Version
- Bumped from **1.2.17** to **1.2.18** — slash command authority tightening.
---
## 1.2.17 — May 13, 2026
### Bug Fix — Missing Glyph Boxes in Sync Debug Popup
Several Unicode characters used in user-facing strings were rendering as missing-glyph boxes because WoW's default font (Friz Quadrata) doesn't include them. Fixed:
- **Sync Debug popup recording dot.** `|cff33ff99●|r recording` / `|cff888888○|r stopped` rendered as boxes. Replaced with bracketed ASCII labels: `[REC]` (green) / `[ -- ]` (gray). The label now reads cleanly and the color carries the meaning.
- **Sync Debug popup footer hint.** `Copy → paste log to Discord` had the right-arrow rendering as a box. Replaced with `Copy >> paste log to Discord`.
- **Footer sync indicator tooltip.** Two tooltip / chat strings that referenced `Settings → Sync & Data` had the arrow rendering as a box. Replaced with `Settings > Sync & Data`.
- **Footer "Syncing…" label and tooltip.** The horizontal ellipsis (`…`, U+2026) was inconsistently substituted for `...` across two strings; unified them to `Syncing...` everywhere.
The middle-dot bullet (`·`, U+00B7) and em-dash (`—`, U+2014) ARE in Friz Quadrata's glyph table and render fine — those stay.
**Codebase audit.** Grepped every Lua file for the suspect characters in user-visible call paths (`SetText`, `ns.Print`, `AddLine`). All remaining occurrences are inside Lua comments (which don't render) so the addon is fully clean now.
### Version
- Bumped from **1.2.16** to **1.2.17** — glyph-rendering polish.
---
## 1.2.16 — May 13, 2026
### Bug Fix — Critical Load-Time Parse Error
**`UI.lua:8970` — addon failed to load with "function arguments expected near 'and'".**
The Sync Debug popup's `OnVerticalScroll` handler used Lua's colon method-call syntax (`self:GetVerticalScrollRange`) as a method-existence probe — but Lua's grammar only allows `obj:method` immediately followed by call args (parens, string literal, or table literal). The bare `self:GetVerticalScrollRange and 0` is a parse error, which killed the entire UI.lua chunk at addon load. Every player who opened the game with v1.2.14 or v1.2.15 hit this.
Replaced the antipattern with dot-syntax method probing:
```lua
if self.GetVerticalScrollRange then
yRange = self:GetVerticalScrollRange() or 0
end
```
(Existence check uses `self.GetVerticalScrollRange` field access; the actual call uses `self:GetVerticalScrollRange()` once we know it's there.)
This was introduced in v1.2.14 with the Sync Debug recorder and went undetected because I only tested the popup-creation path, not its OnLoad parse. Now fixed and scanned the entire codebase for the same pattern — no other instances.
### Version
- Bumped from **1.2.15** to **1.2.16** — load-time crash fix.
---
## 1.2.15 — May 13, 2026
### Feature — Prune Ex-Guildies
Added a one-click way to clean up roster entries for players who have left the guild. Lives at **Settings → Officer Settings → Mass Edit → Roster Maintenance → Prune Ex-Guildies**.
**How it works.** Clicking the button opens a preview dialog that compares the live guild roster (fresh `GuildRoster()` call, not cached) against the EPGP roster and lists every player who's in the DB but no longer in the guild. Each row shows their short name, class color, current EP / GP, and last-seen date so the officer can sanity-check before pulling the trigger. A second StaticPopup confirmation appears on click — two clicks to delete, no accidental wipes.
**What's preserved:**
- **Alts whose main is still in-guild.** If `alts[someAlt] = someMain` and `someMain` is in the guild, `someAlt` is kept (their points still feed the main).
- **The local player.** Never prunable, even on a realm-suffix mismatch.
- **History rows.** Every EP / GP / DECAY history row attributed to the pruned player stays in the audit log — only the live roster row goes away. That means past raids still show who was there and what they were awarded.
**Sync — `ROSTER_DEL` message type (new).** Each removal fires a `ROSTER_DEL` to every peer carrying just the normalized player name. Receivers gate on officer-rank (identical to `DELTA` / `HIST_DEL` / `SETTINGS` authority — a regular guild member can't inject one to evict someone from everyone's database). After all removals land, a `SendFull` follows 0.5 s later to catch any peer that missed the per-name broadcast (their dbRev gap from the local bumps triggers a re-pull anyway, but the explicit FULL guarantees convergence).
**Why this matters.** Before today, the only way to clear ex-guildies was manual `/vcl reset` (nuclear, wipes everything) or per-player CSV-export-edit-import. Now: open Mass Edit → Prune → Confirm. Three clicks for what used to take five minutes.
### Sync Debug
- The new `ROSTER_DEL` handler is fully instrumented for the v1.2.14 Sync Debug recorder. Drops, authority rejects, and applied removals all surface as live log lines so an officer can verify their prune is propagating in real time.
### Version
- Bumped from **1.2.14** to **1.2.15** — Prune Ex-Guildies officer action.
---
## 1.2.14 — May 13, 2026
### Feature — Sync Debug Recorder
Added a live sync activity recorder so raid leads can troubleshoot sync problems the moment they happen, instead of asking "did everyone get the EPGP update?" and getting silence back.
**Where to find it.** Settings → Sync & Data → Troubleshooting → **Start Sync Debug**. The button opens a 720×460 popup window and turns recording on. The window also auto-enables recording when you open it (so you don't have to remember a separate "record" toggle).
**What gets recorded.** Every message that crosses the sync boundary, color-coded by category:
- **OUT (cyan)** — every outgoing send, with destination (`GUILD` / `RAID` / whisper target) and payload size. Chunked sends, urgent sends, and queued sends are all distinguished.
- **IN (white)** — every incoming message that was accepted and applied. Includes the msg type (`HEARTBEAT`, `FULL`, `DELTA`, `BATCH_EP`, `RAID`, `SETTINGS`, `ROLL_START`, `ROLL_END`, `PROFILE`, `HIST_DEL`) and the sender's short name.
- **DROP (yellow)** — every incoming message that was rejected, with the **exact reason**. Examples: "DELTA from Foo dropped: sender not officer", "FULL from Bar dropped: stale rev 42 ≤ local 47", "BATCH_EP dropped: sender not raid leader". This is the box you watch when "sync isn't working" — the reason text tells you whether it's an authority issue, a rev issue, or a malformed payload.
- **ERR (red)** — sync-side errors: chunk reassembly timeout, malformed JSON payload, parse failure.
- **INFO (gray)** — state changes that matter for sync correctness: explicit-request window opening, chunk completion, debug start/stop.
**Controls on the popup:**
- **Filter buttons** (All / Out / In / Drops / Errors / Info) — click to focus on just one category. Drops + Errors is the usual triage view.
- **Pause / Resume** — freeze the scroll while you read a specific line without losing incoming events (they keep being recorded, just not auto-scrolled).
- **Clear** — wipe the buffer (with confirm) without disabling recording. Useful for "let me start fresh and reproduce the bug."
- **Copy** — opens a copy-dialog with the full filtered log so you can paste into Discord or a bug report.
- **Stats footer** — running counts of OUT / IN / DROP / ERR / INFO since recording started.
**Performance.** The instrumentation is essentially free when recording is off: every dbg() call in Sync.lua is a single nil-check + early return. When on, each event is one `string.format` + `table.insert` + listener fanout — comfortably under a millisecond at raid-level traffic. The log buffer is a circular 500-entry ring so it can't grow without bound; oldest entries drop off the front. FontString rendering uses a pool so the popup doesn't churn GC even at 100 events/min.
**Sync.lua instrumentation.** Sprinkled `dbg(category, fmt, ...)` calls at every key decision point:
- `Sync:Send` logs OUT for every urgent / queued / chunked send.
- `Sync:HandleMessage` logs IN on accept or DROP-with-reason on reject for every msgType.
- `Sync:RequestFull` logs INFO when the 90s explicit-request window opens.
- `feedChunk` logs ERR on chunk timeout and INFO on completion.
This is the missing observability layer for the sync subsystem — you no longer have to guess what's happening over the wire.
### Version
- Bumped from **1.2.13** to **1.2.14** — sync debug recorder.
---
## 1.2.13 — May 9, 2026
### Feature — Sync Status Footer Indicator Reworked
The footer's sync indicator was previously a static colored dot with a "Sync on / off" label that only updated when you switched tabs and offered no detail beyond the colour. Replaced with a live, interactive sync status pill:
**Five distinct states** (was three):
- **Sync off** — gray dot, "Sync off" label. Sync disabled in settings.
- **Syncing…** — pulsing yellow dot. A FULL is in flight (with a 90 s timeout — no more permanent pulse if the request goes unanswered).
- **No peers** — yellow dot. Sync is on but no heartbeats received yet (cold start).
- **Behind N** — red dot, shows the exact gap. "Click to pull latest" cue in the tooltip.
- **In sync / Leading** — green dot. Local rev matches the highest peer (or you're ahead due to a local change that hasn't propagated yet).
**Live updates.** The footer now refreshes on a 1 Hz ticker (OnUpdate on the footer frame, no cost while the window is hidden) so the indicator reflects reality at all times — not just the last time you switched tabs.
**Hover tooltip** with the full diagnostic picture:
- Status interpretation in plain English (in sync / behind by N / no peers / syncing now / leading).
- Local rev + season.
- Highest peer rev.
- Best peer name + rev (whoever the click action would target).
- Total peers heard.
- Last sync time + sender ("3m ago from Thrall").
- Click-to-act cue when applicable.
**Click to sync.** The whole indicator is now clickable — clicking it triggers `RequestFullFromBestPeer` (same as the new Settings → Sync & Data button) so you can pull the latest from anywhere in the UI without navigating to settings. A guard prints a friendly warning if sync is disabled.
**Raid status footer** now also shows the kill count ("Raid: Black Temple (5 kills)") at-a-glance.
**Internals.** The 90 s "syncInProgress" timeout uses `ns.Sync._uiSyncCheckStartedAt` (a hidden field on the Sync module) so a stale flag is forcibly cleared and the UI doesn't pulse forever when a request goes unanswered (e.g. all officers offline, dropped REQ FULL, etc.).
### Version
- Bumped from **1.2.12** to **1.2.13** — sync status footer rework.
---
## 1.2.12 — May 9, 2026
### Bug Fixes
**Test.lua — `AwardEP noBump` self-test false-failed**
- The test created its synthetic player implicitly via `AwardEP`, which calls `GetOrCreatePlayer` internally — and `GetOrCreatePlayer` bumps `dbRev` once when inserting a brand-new roster row. That bump was being attributed to `AwardEP`'s `noBump = true` call and flagging the test FAIL. Pre-create the synthetic player BEFORE measuring rev so the test isolates what it's actually checking.
**Sync.lua / DB.lua — raid log showing "Unknown" zone**
- The receiver-side stub-raid creation path (used when `RAID/KILL` arrives at a client that missed the original `RAID/START`) set `zone = "Unknown"` as a sentinel. The subsequent `RAID/START` handler bailed on `if not exists`, so the stub's "Unknown" zone was sticky — even when the real `RAID/START` eventually landed it was silently dropped. Two layers of fix:
- `RAID/START` handler now **backfills** stub raid metadata in place (zone / leader / start / attendee snapshot), updating any field still at its sentinel default. Existing fully-populated raid records aren't touched.
- `DB:Initialize`'s heal pass also looks up `ns.C.BOSS_LIST[npcId].zone` from any recorded kill in the raid and writes that into `r.zone` if it's missing or `"Unknown"`. **Existing broken records repair themselves on the next `/reload`.**
### UI Polish — Settings → Sync & Data Rework
Renamed and re-laid out the panel into three clear sections:
**Sync**
- "Enable sync with other addon users" (was "Full guild sync enabled")
- "Accept pushed settings from officers" (unchanged)
- **Pull Latest from Best Peer** (renamed from "Request Full Sync") — whispers the highest-rev peer; one targeted response.
- **Broadcast Sync Request** (new, was hidden behind `/vcl sync force`) — the broadcast fallback when no peer is known yet. Hint warns "use only if Best Peer fails."
- **Re-broadcast My Profile** (new) — force-sends your gear / wishlist / spec to all peers immediately, bypassing the 15 s cooldown. Useful when someone says "I don't see your wishlist" mid-raid.
**Officer Push** (officer-only actions)
- **Push Settings** (was "Push Settings to Guild") — send guild-policy EPGP settings to all peers.
- **Push Full Database** (was "Send Full DB to Guild") — primary-styled. Hint now says "EPGP + history + raids + profiles" so the inclusion of the profiles bundle is explicit.
**Status** (live diagnostics)
- Last sync line (existing).
- **Database: rev N (season S) — Highest peer: M — (in sync / behind)** with green/yellow color cue.
- **Profile DB: N entries (survives `/vcl reset`) — Peers heard: M** so the user can see the profile DB exists and is populated.
The user can now answer "are we in sync?" at a glance without leaving the panel.
### Internals
- `Sync:BroadcastProfile(name, msRole, osRole, osRaidReady, force)` — new `force` flag bypasses the 15 s cooldown for explicit user actions (the Re-broadcast button, and the login bootstrap which was already doing this manually by resetting `_profileLastSend`).
- `Sync:RebroadcastOwnProfile(force)` — passes through.
### Version
- Bumped from **1.2.11** to **1.2.12** — raid-zone heal + sync-panel rework.
---
## 1.2.11 — May 9, 2026
### Feature — `/vcl test` Self-Test Harness
New in-game test harness for verifying the addon's behavior. Four subcommands:
- **`/vcl test`** — runs the full self-test suite and reports PASS / FAIL per check. Covers tier-token GP overrides, Eredar Twins encKey dedup map, histKey behavior (per-sender disambiguation), AwardEP's `atTime` and `noBump` parameters, `DB:ReverseHistoryEntry` for both EP and GP-on-alt-charges-main, profile DB round-trip, raid-duration heal and 24-hour sanity cap, bench/attendees mutual exclusion, BATCH_EP credit exclusion, encKey dedup window, `VCLootProfilesDB` SavedVariable structure, and version consistency. Tests are self-contained — they use synthetic names like `VCLootSelfTest-*`, clean up after themselves, and broadcast nothing.
- **`/vcl test info`** — prints addon state at a glance: dbRev, season, highest peer rev, sync state, record counts (roster / profiles / raids / history), EPGP settings summary, active raid info.
- **`/vcl test peers`** — lists every addon user we've heard from via heartbeat in the last ~10 minutes, with their version and rev relative to ours (ahead / behind / equal). Useful for confirming that other raiders' clients are in sync before a raid.
- **`/vcl test gp <itemLink>`** — explains how GP is computed for a specific item: itemId, quality, ilvl, slot, any tier-token override, any custom-GP override, and the final MS / MINOR / OS values. Shift-click an item into the chatbox to fill the link.
Multi-client behaviors (popups appearing on viewer clients, `/vcl sync` round-trips, bench sync arriving on peers, etc.) can't be verified solo; `/vcl test peers` plus `/vcl test info` give you the live signals to check those during raid.
### Version
- Bumped from **1.2.10** to **1.2.11** — self-test harness.
---
## 1.2.10 — May 9, 2026
### Bug Fix — Raid Durations Showing Hundreds of Thousands of Hours
Raid log feedback: raid durations were displaying as values like *"472,113h 22m"* instead of the actual ~3-4 hour run.
**Root cause.** The receiver-side stub-raid creation path in `Sync.lua` (used when a `RAID/KILL` arrives at a client that missed the original `RAID/START`) set `start = 0`:
```lua
raid = {
id = raidId, start = 0, ["end"] = nil, ...
}
```
When the raid eventually got an end timestamp (via a later `RAID/END` or via a FULL sync), the duration calculation `raidEnd - raidStart` became `~1.7 billion - 0` = ~1.7 billion seconds = ~472,000 hours. The UI dutifully formatted that as `"472113h 22m"`.
**Fix.** Three layers:
1. **Stub creation (`Sync.lua`).** The `raidId` itself encodes the original raid start time as its leading numeric prefix (`DB:StartRaid` builds the id as `tostring(time()).."-"..leader`), so we can recover the real start even without `RAID/START`. Stubs now parse `start` from the id; if that fails, they fall back to the killed-boss timestamp.
2. **UI duration formatting.** `BuildRaids` and the raid detail popup both recover `start` from the `raidId` prefix when `r.start` is missing/zero, and `formatDuration` clamps anything above 24 hours or below 0 to `"--"` so any other future stub leak can't render absurd values.
3. **One-time DB heal at init.** `DB:Initialize` walks every raid record on load; any with `start = 0` get repaired in-place from the `raidId` prefix (or the earliest known kill time as a fallback). Existing already-broken raid log entries fix themselves on the next `/reload`.
### Version
- Bumped from **1.2.9** to **1.2.10** — raid-duration display fix.
---
## 1.2.9 — May 9, 2026
### Bug Fix — `/vcl sync` Silently Failed When the Best-Rev Peer Was a Non-Officer
Raid feedback: clicking the Request Full Sync button printed *"Syncing from <player> (rev N)…"* but the FULL never arrived and points stayed outdated. The user's raid leader had the freshest data but was a Veteran Raider rank-wise — and v1.2.0's trust gates blocked the entire exchange:
- **Sender side:** the REQ FULL handler had `if not (ns.isOfficer and ns.isOfficer()) then return end`. A non-officer raid leader received the whispered REQ but bailed out without responding.
- **Receiver side:** even if a non-officer DID reply, the FULL handler had `if not senderIsOfficer(sender) then return end`. The asker would reject the response.
So the sync request was silently dropped on **both** ends whenever the best peer was a non-officer with the freshest data. The "Syncing from…" UI message is just a local print — it had no idea the round-trip never closed.
**Fix.** Open a 90-second "explicit-request window" whenever we send a REQ FULL. Inside that window:
1. **REQ FULL handler:** non-officers reply if-and-only-if the REQ came in via WHISPER (i.e. the asker explicitly targeted them). Broadcast REQs are still officer-only, so non-officers don't all queue simultaneous FULL transfers and re-introduce the channel flood.
2. **FULL handler:** a non-officer FULL is accepted if (a) we're inside our own request window, AND (b) either the response came from the peer we targeted, or we sent a broadcast REQ with no specific target. The window closes the moment one non-officer FULL applies, so subsequent unsolicited spoofs can't sneak in.
Officer FULLs are still always accepted regardless of window — the existing trust path is unchanged.
**Net effect.** When you click Request Full Sync and the best-rev peer is your raid leader (officer or not), the round trip now actually completes and the FULL applies. The trust gate against unsolicited / spoofed FULLs is preserved for every other case.
`OnMessage` → `HandleMessage` now also threads the message's `channel` parameter so the REQ handler can distinguish whispered vs broadcast.
### Version
- Bumped from **1.2.8** to **1.2.9** — `/vcl sync` works against non-officer peers.
---
## 1.2.8 — May 9, 2026
### Bug Fix — "Always One Boss Behind" on Boss-Kill EP
Raid feedback: the raid leader's points updated immediately on every boss kill, but the loot master's addon was always one boss behind — couldn't sync to the raid leader's latest data until the *next* boss died.
**Root cause.** The BATCH_EP receiver iterated **the receiver's own local `raid.attendees` and `raid.bench`** to decide who got credited:
```lua
for name in pairs(raid.attendees or {}) do applyOne(name, ep, reason) end
```
This made BATCH_EP correctness dependent on the receiver's local raid state being **complete and up to date** at the moment BATCH_EP processed — which in turn meant that the prior RAID/START broadcast AND every subsequent RAID/KILL embedded snapshot AND every BENCH_ADD had to have all landed and been processed first. Any gap (channel reorder, RAID/KILL chunk still in flight, dropped message, late join, sender-authority transient miss) caused some or all attendees to silently miss their EP credit. The data only converged later via heartbeat-driven FULL syncs — which is exactly the "always one boss behind" symptom: BATCH_EP for boss N landed but credited an empty/stale local set, then HB → REQ FULL → FULL response from the raid leader applied boss N's data via the FULL's roster snapshot, around the time boss N+1 was being killed.
**Fix.** Embed the sender's authoritative attendee + bench lists directly in the BATCH_EP payload. The receiver applies credits using the **broadcast's** lists, not its local raid state, so BATCH_EP becomes self-contained — no dependency on prior RAID/KILL having landed first or on any other raid-state sync being current.
```lua
-- new payload: raidId \t ep \t reason \t by \t timestamp \t benchEP \t attCsv \t benchCsv
```
Pre-1.2.8 senders omit the trailing fields and the receiver falls back to local state exactly as before, so the change is backward compatible. Pre-1.2.8 receivers ignore the trailing fields (they only `strsplit` the first 6) and continue to use local state — also backward compatible.
**Size and DC envelope.** The new payload is up to ~620 bytes for a 25-player raid (vs ~80 before). At `CHUNK_SIZE = 215` that's 3 chunks at 50 ms = 150 ms send time per BATCH_EP. Well within the existing 20 chunks/sec ceiling. No DC-risk increase.
### Version
- Bumped from **1.2.7** to **1.2.8** — BATCH_EP self-contained delivery.
---
## 1.2.7 — May 9, 2026
### Bug Fix — Roll Popup Reliably Shows on Every Round (For Real This Time)
Raid feedback: the roll popup showed up for the first item but not for any item after that.
**Root cause.** v1.2.1's `OnRollStartBroadcast` had a "preserve concurrent leader session" branch:
```lua
if L.session then
if not L.session.viewer then return end -- preserve leader session
...
end
```
The intent was to protect a local leader's actively-running roll if a *second* officer started a concurrent roll. In a single-officer raid that branch becomes a defect: any state that ever pinned `L.session.viewer` to `false` on a viewer's client (an alt-click that opened a prompt but never started, an earlier `/vcl roll` test, an aborted Award flow that errored before `L.session = nil`, an officer alt who clicked something while spectating) silently swallowed every subsequent ROLL_START on that client. First roll shows because L.session is nil; second roll forever-after doesn't because L.session is stuck non-viewer.
**Fix.** Drop the leader-preservation branch entirely. `OnRollStartBroadcast` now unconditionally tears down any existing session and creates a fresh viewer session for the incoming broadcast. Reliability beats the rare concurrent-officer corner case — and our self-echo guard already prevents a leader's own broadcast from ever clobbering their own session, so the only thing actually given up is the multi-officer-concurrent-rolls scenario, which works fine if officers coordinate to roll one at a time.
**Plus a defensive guard.** `OnRollEndBroadcast` now ignores a ROLL_END whose `itemLink` differs from the active session's. Out-of-order delivery of a stale ROLL_END from a prior roll (unlikely under normal channel ordering but possible during officer-to-officer handoffs or saturated channels) would otherwise close the wrong session's window.
### Version
- Bumped from **1.2.6** to **1.2.7** — roll-popup reliability fix.
---
## 1.2.6 — May 8, 2026
### Bug Fixes — Permission Consistency for Non-Officer Raid Leaders
A non-officer Veteran Raider holding the raid leader slot would silently fail in three places:
**Attendance.lua — auto-start of raid tracking would silently bail**
- `OnPlayerEnterCombat` and `OnEncounterStart` correctly fire on the raid leader's client (`isRaidLeader()` check), but they then call `self:StartRaid()` — which had a stricter `isOfficer()` gate. Non-officer leaders saw a brief permission warning the moment combat opened and the raid never auto-started. Loosened to `canManageRaid()` (officer OR group leader) — same authority pattern as `canRunLoot` in `Loot.lua` and `EndRaid` already uses.
**Whisper.lua — `!standby` sent to a non-officer raid leader was redirected**
- Last version's anti-desync gate refused to process the bench-add unless the recipient was an officer. A non-officer raid leader received `!standby` whispers, replied "please whisper an officer or raid leader", and the whisperer was sent to find someone else. Now accepts the raid leader too via the new shared `ns.canManageRaid` helper.
**Attendance.lua — streak bonus would land only on a non-officer leader's own client**
- `CheckStreakBonus` (called from `EndRaid`) broadcasts per-player DELTAs, which receivers gate on `senderIsOfficer`. If a non-officer raid leader ended the raid, the streak bonus would apply locally on their client and **nowhere else** — visible to the leader and invisible to every raider. Now skipped entirely on non-officers; either an officer ends the raid (streak fires for everyone) or no one's streak is awarded (consistent state across the guild).
### Version
- Bumped from **1.2.5** to **1.2.6** — non-officer raid leader permission fixes.
---
## 1.2.5 — May 8, 2026
### Bug Fix — Tier Tokens Charged Far Less GP Than the Gear They Redeem For
Tier-token GP was being computed against `GetItemInfo`'s metadata for the *token itself*, not the redeemed gear:
- **`equipLoc` is empty** on tokens (a token isn't equippable), so `slotFactors[""]` falls through to the 1.0 default — wrong for any gear slot whose factor isn't 1.0 (shoulders, hands, wrists, waist, feet, neck, finger).
- **`ilvl` is the token's** item level, which for **T6** (Hyjal / Black Temple) is 141 while the redeemed gear is 146, and for **Sunwell tier** the token sits a few levels below its redeemed counterpart too. Because the GP formula has an exponential ilvl term, a 5-level shortfall translates into roughly a **30 GP undercharge** on every T6 piece awarded as a token.
A `Loot:ComputeGP` lookup now consults the new `ns.C.TIER_TOKEN_INFO` map (in `Constants.lua`) before the formula runs, substituting the canonical `(slot, ilvl)` of the redeemed gear for every TBC tier token (T4 / T5 / T6 / Sunwell + the Sunwell Sin'dorei Pendant and Band tokens). After this fix, awarding a tier token charges the same GP as awarding the redeemed gear directly.
Per-item `CustomItemGP` overrides still take priority — officers can manually override any token's GP if they prefer different values.
### Version
- Bumped from **1.2.4** to **1.2.5** — tier-token GP correctness.
---
## 1.2.4 — May 8, 2026
### Bug Fixes — EP / GP Correctness
**Attendance.lua / Sync.lua — bench/attendees double-credit on boss kill**
- A raider who whispered `!standby` while still in the raid group ended up in **both** `raid.attendees` (auto-snapshotted by `SnapshotRaid`) and `raid.bench` (added by `BenchAdd`). On every boss kill they were credited as an attendee AND as bench — double EP. Fixed three ways:
- `BenchAdd` now removes the player from `raid.attendees` (mutual exclusion at the source).
- `SnapshotRaid` skips bench-listed players when auto-adding raid members to attendees (so a re-snapshot can't undo the BenchAdd removal).
- `OnBossKilled`, `OnBossWipe`, `MassAwardEP`, and the `BATCH_EP` receiver all skip attendees who are also on bench — defense in depth in case the two sets ever drift on a single client.
**Sync.lua — bench updates didn't propagate, BATCH_EP credit diverged across clients**
- `BenchAdd` and `BenchRemove` only mutated the local `raid.bench` table; there was no incremental sync. Officers' clients had the correct bench, but every other receiver's bench stayed empty until the next FULL sync. The result: the leader credited bench EP on their own client; every other client either credited the same player as a regular attendee, or didn't credit them at all. Fixed by adding two new RAID-message subtypes:
- `BENCH_ADD` — broadcast on every `BenchAdd`; receivers add the name to their `raid.bench` and remove it from `raid.attendees`.
- `BENCH_REMOVE` — broadcast on every `BenchRemove`; receivers remove from `raid.bench`.
- Authority on receive: leader-or-officer (matches the rest of the RAID-subtype branch).
**Whisper.lua — `!standby` from the wrong recipient silently desynced bench**
- The `!standby` handler ran on whichever raider received the whisper. If that raider wasn't an officer or the raid leader, the resulting `BENCH_ADD` broadcast would be dropped by every other client's authority gate, leaving bench out of sync. Handler now only processes on officers/raid leader; non-authoritative recipients reply telling the whisperer to message someone who can act.
### Wastefulness — `StartRaid` migrated from N DELTAs to BATCH_EP
- `StartRaid` previously sent up to **50 individual `BroadcastDelta` messages** for a 25-player raid (one per attendee for `epPerRaidStart`, another per attendee for `epPerTimeOnTime`). Each was small and queued at 50 ms, so the per-second rate stayed safe — but it was wasteful traffic that queued behind any concurrent FULL transfer. Migrated to `BATCH_EP`: at most **2 messages total** per raid start (one per EP type), regardless of raid size.
### Memory — `MAX_RAIDS` cap on local raid storage
- The `raids` table was unbounded; a year-round raiding guild would slowly accumulate megabytes of stale raid records (kills + attendees + bench × hundreds of weeks). New `MAX_RAIDS = 500` cap trims the oldest *completed* raids when exceeded — about a full year of weekly 25-mans, well past any window `AttendancePct` looks at. In-progress raids are preserved regardless of age.
### Disconnect-Risk Audit — clean
A full pass over every outbound code path in the addon:
- All chunked sends still route through the 50 ms / 20 chunks/sec ticker.
- No new code path introduces a tight loop that bypasses the queue.
- `BENCH_ADD` / `BENCH_REMOVE` are single small messages.
- `StartRaid`'s migration to `BATCH_EP` *reduces* peak outbound (2 messages instead of 50).
- The new `MAX_RAIDS` trim is local-only — no network traffic.
No DC vector identified. Same safety profile as 1.2.3.
### Version
- Bumped from **1.2.3** to **1.2.4** — bench-credit correctness + StartRaid efficiency.
---
## 1.2.3 — May 8, 2026
### Bug Fixes — Boss Kill EP
**Attendance.lua / Constants.lua — Eredar Twins triple-credited**
- Eredar Twins (Sunwell Plateau) awarded boss-kill EP **three times per encounter**: once when Sacrolash died (`COMBAT_LOG`), once when Alythess died (`COMBAT_LOG`), and once when `ENCOUNTER_END` fired for the encounter as a whole. The cross-handler dedup keyed on `bossName:lower()` and the three calls had three different names, so none collided. Fixed by introducing an `encKey` parameter on `OnBossKilled` and a new `ns.C.NPC_TO_ENCOUNTER` map: every peer NPC of a multi-NPC encounter and the canonical encounter name all hash to the same dedup bucket. The dedup window also widens from 5 s to 60 s so a peer NPC dying a few seconds after its sibling doesn't slip past.
- Existing single-NPC bosses are unaffected — `encKey` defaults to `bossName` and the dedup behaves identically.
**Attendance.lua / DB.lua — silent history-row duplication on FULL sync after a boss kill**
- `OnBossKilled` (and `OnBossWipe`, and `MassAwardEP`) snapshot a single timestamp `ts` and pass it as a new optional `atTime` arg to `AwardEP` so every locally-inserted history row uses the same time. Previously `AwardEP`'s internal `time()` could drift across a second boundary on a slow 25-player loop, producing rows whose `histKey` (`time | type | who | amount | by`) didn't match the receiver-side rows the BATCH_EP broadcast created. On the next FULL sync, `mergeHistory` saw the sender's `T+1` rows as new entries and added them on top of the receiver's `T` rows — silently doubling the visible log for that kill on every receiver. EP/GP totals were unaffected (the BATCH_EP receiver dedup caught the EP side), but the log was wrong.
- `AwardEP` and `AssignGP` gain the optional `atTime` parameter; existing 5-arg callers continue to work unchanged.
**Attendance.lua / DB.lua — every boss kill caused a wasted FULL-sync request**
- The leader's `AwardEP` loop bumped `dbRev` per call (25 times for a 25-player kill) while the receiver-side BATCH_EP handler bumps once. The leader was always 24 revs ahead after every boss kill, so every receiver's heartbeat saw "leader is ahead → request a FULL." `AwardEP` gains an optional `noBump` flag; `OnBossKilled` / `OnBossWipe` / `MassAwardEP` pass it on each loop iteration and call `Bump()` exactly once at the end, putting sender and receivers at the same `dbRev` after the broadcast applies.
**Attendance.lua — `MassAwardEP` (`/vcl award all`) used N per-player DELTAs**
- The mass-award command looped 25 individual `BroadcastDelta` messages — exactly the per-player flood that `BATCH_EP` was created to prevent. Each was queued at 50 ms so it stayed under the per-second ceiling, but it was wasteful (25 chunks vs 1) and queued behind any concurrent FULL transfer. Now uses a single `BATCH_EP` broadcast.
### Disconnect-Risk Audit — Boss Kill Path
A 25-player boss-kill mass-award produces, on the leader's wire, **two outgoing messages total**:
1. One `RAID/KILL` (small, ~1 chunk)
2. One `BATCH_EP` (small, ~1 chunk)
Both go through the existing 50 ms ticker so they're paced at 20 chunks/sec. Total channel time used by the leader: ~100 ms. Receivers process locally and do **not** broadcast in response — the `seenKeys` dedup in the BATCH_EP handler also catches any redelivered echo. Combined with this version's "Bump once" fix above, no follow-on FULL request is triggered either. **No 25-player flood. No per-player burst. No DC vector.**
### Version
- Bumped from **1.2.2** to **1.2.3** — boss-kill correctness fixes.
---
## 1.2.2 — May 8, 2026
### New SavedVariable — `VCLootProfilesDB`
Per-player gear, wishlist, main spec, off spec, off-spec ready flag, and class are now stored in a separate SavedVariable that is **decoupled from the EPGP database**. It survives `/vcl reset`, season bumps received over the wire, and any other path that wipes `VCLootDB`. On first load after upgrade, profile fields are migrated automatically out of the existing roster — there is no manual step.
The roster record continues to mirror the same fields, so every existing read site in `UI.lua` (and elsewhere) keeps working without changes.
### Bug Fixes — Gear / Wishlist Sync
**Sync.lua — PROFILE handler dropped data when the player had no roster row**
- Players who had never been awarded EP had no roster record; their incoming `PROFILE` broadcasts were silently discarded forever. The handler now writes to `VCLootProfilesDB` unconditionally and only mirrors onto the roster record if one exists. Their gear and wishlist are now visible from the moment the first broadcast lands.
**DB.lua — gear and wishlist were destroyed by `/vcl reset`**
- `ResetPreserveProfiles` snapshot only msRole / osRole / osRaidReady / wishList in memory, but the season bump it triggered caused every receiver's FULL handler to *hard-wipe* the entire roster — including their own local profile fields, which the broadcasting officer never had. Profile data now lives in `VCLootProfilesDB` (separate SavedVariable) and is re-applied to recreated roster records both locally (`HydrateRosterFromProfiles`) and on receipt of a season-bump FULL (`RestoreAllProfilesToRoster`).
**Sync.lua — `BroadcastProfile` cooldown was 60 seconds**
- Spec changes, wishlist edits, and gear swaps appeared stale on peers for nearly a full minute. Reduced to 15 seconds. The 2-second debounce in front of it still collapses rapid bursts (e.g. spec dropdown clicks) into a single send.
### Sync Improvements — Faster, More Complete Profile Propagation
**Sync.lua / Core.lua**
- `GROUP_ROSTER_UPDATE` (joining a raid, someone joining the raid you're in) now re-broadcasts your own profile after a 2-6 s random stagger, so a player who just joined sees existing members' gear / wishlist / spec within seconds rather than waiting for each peer to manually trigger a change.
- `PLAYER_EQUIPMENT_CHANGED` is now hooked: gear swaps refresh the local snapshot and broadcast (subject to the same 15 s cooldown), so peers see your current gear without you having to open the Profile tab.
- `Sync:RebroadcastOwnProfile()` exposed for any future callers that want to push the local player's profile on demand.
**Sync.lua — FULL payload now includes a PROFILES bundle**
- A new `---PROFILES---` section ships every known player's class / spec / wishlist / gear so that a fresh client (new guildmate, returning member, leveling alt) learns the full profile picture from a single FULL response. Previously profiles only propagated as players logged in and broadcast individually, so a new joiner could go weeks without seeing some members' data. Pre-1.2.2 senders simply omit the section and 1.2.2+ receivers fall back gracefully.
- The bundle is capped at the 30 most-recently-updated profiles (`PROFILES_SYNC_MAX`) so the FULL payload stays inside WoW's safe per-client outgoing-quota envelope. Anything past the cap continues to propagate via per-player PROFILE broadcasts. A belt-and-suspenders hard guard (`FULL_SIZE_DC_LIMIT`) drops the bundle entirely if the assembled payload would exceed ~160 KB / ~37 s of sustained sending — receivers fall back to per-player PROFILE messages.
- The season-bump branch in the FULL handler now calls `RestoreAllProfilesToRoster` after the hard wipe, so receivers' own gear and wishlist re-appear on the freshly-rebuilt roster instead of being permanently destroyed.
### Disconnect-Risk Audit
A full pass over the v1.2.0 / 1.2.1 / 1.2.2 changes confirmed every code path that emits addon messages still routes through the existing 50 ms ticker (`enqueueSend`) at 20 chunks/sec. No change increases per-client *outgoing* rate above the pre-existing safe ceiling:
- `PROFILE_COOLDOWN_S` (60 → 15 s) caps a single client to ~7-8 chunks per 15 s = 0.5 chunks/sec average.
- `GROUP_ROSTER_UPDATE` and `PLAYER_EQUIPMENT_CHANGED` re-broadcasts are fronted by the same 2 s debounce + 15 s cooldown, so bursts collapse to one send.
- Cancel/Disenchant `ROLL_END` adds a single small message per cancellation (rare).
- The FULL `PROFILES` bundle's length is bounded by `PROFILES_SYNC_MAX` and the hard guard above; the per-second send rate is unchanged.
Net effect: same safety profile as 1.1.x, with stricter caps in the new code paths.
### Version
- Bumped from **1.2.1** to **1.2.2** — profile decoupling + gear/wishlist sync hardening.
---
## 1.2.1 — May 8, 2026
### Bug Fixes — Roll Window
**Loot.lua — viewers' roll window not popping up**
- Fixed stale viewer sessions silently blocking new ROLL_START broadcasts. When a leader hit Cancel or Disenchant on a roll, viewers' windows stayed open until a 30-second auto-close timer fired. Any new ROLL_START during that window was silently dropped because `OnRollStartBroadcast` did `if hasActiveSession() then return end`. The handler now tears down a stale **viewer** session and accepts the new broadcast (a local **leader** session is still preserved so two concurrent officers don't clobber each other).
- Cancel and Disenchant now broadcast a roll-end signal (empty winner) so every viewer's window closes immediately. Previously viewers waited up to 30 seconds for their auto-close fallback.
- Fixed `OnRollStartBroadcast`'s auto-close timer math going negative when `endTime` arrived in the past (clock skew, message delay), which made `C_Timer.After` fire instantly and dismissed the window viewers had just opened. The remaining-seconds calculation is now clamped to a non-negative minimum.
- `OnRollEndBroadcast` no longer prints a "X won Y for 0 GP" line when the payload is the cancellation sentinel.
**Sync.lua — sender-authority cold-start miss**
- Fixed the rank cache returning nil for legitimate officer broadcasts in the first ~10 seconds after `PLAYER_LOGIN`, before the guild roster has arrived from the server. `getSenderRank` now force-refreshes once when the cache is entirely empty. `Core.lua` also pre-warms the roster at login.
### Feature
**UI.lua — Pass keeps the window open for the loot master**
- The Pass button on the roll window now behaves differently by role: viewers (regular raiders) close the window as before; **leaders / officers keep the window open** so they can still pick a winner from the leaderboard. Either way the local roll buttons are locked so the player can't accidentally roll after passing. The status label updates to "Passed — pick a winner from the list" for the leader's reference.
### Other Loot-System Fixes
- `OnOpenMasterLootList` now uses `canRunLoot()` (officer **or** group leader) instead of the stricter `isLeader()`. A non-officer designated Master Looter can now have the roll window auto-open on master-loot, matching the behaviour of `/vcl roll` and the alt-click path.
- `StartRoll` now warns the leader when their own sync is disabled while in a group, so they immediately understand why no one else is seeing the roll window.
### Version
- Bumped from **1.2.0** to **1.2.1** — roll-window fixes + Pass-button feature.
---
## 1.2.0 — May 5, 2026
### Security
**Sync.lua — sender-authority gate on every mutating message**
- `DELTA`, `BATCH_EP`, `RAID`, `SETTINGS`, `HIST_DEL`, `ROLL_START`, `ROLL_END`, and `FULL` (including the season-bump hard-wipe path) are now rejected unless the sender holds Vibe Master / Officer / Officer Alt / Loot Master rank, OR is the in-game Master Looter, OR (for leader-or-officer messages) is the current group leader. Previously any guild member could broadcast a forged `DELTA` to award arbitrary EP/GP, push a forged `SETTINGS` to corrupt every roster's GP formula, or wipe every receiver's database via a forged season bump. Guild ranks are looked up via a 30-second cached `GetGuildRosterInfo` table.
- `PROFILE` messages now require the in-payload player name to match the sender. Without this, anyone could overwrite anyone else's role, gear snapshot, and wishlist on every client.
- Numeric `SETTINGS` values are clamped to per-key sane bounds (`gpFinalMult` 0.0001–1000, `decayPercent` 0–100, etc.) so an out-of-range push (or `inf`/NaN) cannot land. `BATCH_EP` and `DECAY DELTA` amounts are clamped to sane upper bounds (≤100 000 EP per kill; 0–100 % decay).
- Only officers reply to `REQ FULL`; non-officer replies would be rejected on receive anyway, and skipping the send saves bandwidth and reduces flood risk.
### Bug Fixes
**Sync.lua**
- Fixed lifetime EP/GP double-count on offline-divergence reconciliation. The old code adopted the sender's `lifeEP` via `max(local, incoming)` and then *also* added local-unique entries' amounts on top, compounding lifetime totals every time a FULL sync triggered the replay path. `applyHistoryEntries` now takes a `replayMode` flag that skips the lifetime bump.
- Fixed `mergeHistory` truncating local history to 1 000 rows when the local DB cap is 2 000. Receiving a FULL no longer silently drops rows the local SavedVariables would otherwise keep — both ends now reference `ns.C.MAX_HISTORY`.
- Fixed `histKey` collisions when two officers awarded the same EP amount to the same player in the same wall-clock second. The dedup key now includes `by`. A `legacyHistKey` fallback keeps pre-1.2 tombstones working during the transition window.
- Fixed `BATCH_EP` re-application on redelivery. Receivers now build a `seenKeys` set from current history and skip rows that already exist, so a redelivered or twice-fired BATCH_EP does not double-credit attendees.
- Fixed `DELTA` re-application on redelivery: receivers now check the canonical histKey against existing history before applying.
- Fixed chunked-transfer expiry being measured from the *first* chunk's wall-clock arrival rather than last activity. A FULL exceeding ~600 chunks (~140 KB) was silently discarded mid-stream. Expiry is now last-activity-based and the window is raised from 30 s to 60 s.
- Fixed self-echo guard's short-name comparison dropping legitimate messages from a same-realm group member who shares a first name with the local player. Comparison now uses normalized full names.
- Fixed `CHUNK_SIZE = 230` cutting close to the 255-byte addon-message cap (worst-case framed header for `BATCH_EP` could push the total over). Reduced to 215 with explicit header-size accounting documented inline.
- Fixed `hasLocalUnique` rev-bump being set against the *unfiltered* local-unique list. Tombstoned entries that had already been deleted elsewhere were counted as new data we needed to advertise, causing peers to needlessly request a FULL from us. Now reflects the post-tombstone-filter count.
- Fixed unbounded growth of `presence` / `inbox` / `reqLastSent` when `syncEnabled = false` (Heartbeat never runs and never prunes). A throttled `maybePrune` runs from `OnMessage` every 60 s.
**Sync.lua / DB.lua / UI.lua — history deletion now reverses EP/GP**
- Centralized `DB:DeleteHistoryEntry(idx)` reverses the entry's EP/GP impact on the roster, drops the row, tombstones the canonical histKey, and bumps `dbRev` in one call. Both the UI right-click delete and the sync `HIST_DEL` handler use it. Previously the row was removed from the visible log but the points it awarded stayed on the player. `applyTombstones` (FULL receipt) now also reverses EP/GP for tombstoned rows.
**Slash.lua — `/vcl sync` no longer floods the guild**
- `/vcl sync` now uses the targeted-best-peer flow (whisper exactly one peer for a FULL response) instead of broadcasting `REQ FULL` to GUILD where every online addon user simultaneously responds. If no peers are known (cold start) it fires a heartbeat, waits 3 s for replies, then falls back. `/vcl sync force` skips the wait and broadcasts immediately.
**Decay.lua**
- Fixed `decayBaseGP = false` (the "protect base GP" mode) silently doing nothing because it referenced `s.baseGP`, but the actual setting key is `s.gpBase`. The floor protection has never engaged since the GP-formula migration; it works correctly now.
**Attendance.lua**
- Wipe-EP awards now use one `BATCH_EP` message instead of N per-player `DELTA` messages, matching the boss-kill flow. This both cuts traffic and lets non-officer raid leaders broadcast wipe credit (DELTA is officer-only post-1.2; BATCH_EP is leader-or-officer).
### Version
- Bumped from **1.1.0** to **1.2.0** — sync hardening + security gates.
---
## 1.0.0 — April 29, 2026
### Bug Fixes
**Loot.lua / Core.lua**
- Fixed double `HandleModifiedItemClick` hook. When Bag Roll modifier was set to Alt (the default), both the Core.lua hook (`Loot:OnAltClick`) and the UI.lua bag-roll hook fired on the same alt-click, opening the roll prompt twice. `OnAltClick` now yields when the bag roll modifier already covers that gesture.
**UI.lua**
- Fixed free roll auto-detection using `GetNumGroupMembers()`. A 25-man raid with fewer than 20 people online would falsely trigger the 10-man free roll default. Detection now uses `maxPlayers` from `GetInstanceInfo()`, which returns the instance's hard cap (10 for Kara/ZA, 25 for 25-man content), and is therefore always accurate regardless of current attendance.
- Fixed free roll checkbox state not being passed correctly to `StartRoll` when unchecked in a 10-man instance. The checkbox visual updated correctly but the state read by the start button could be stale. State is now snapshotted into a local before the prompt frame is hidden.
- Fixed multiple Unicode characters rendering as blank squares in-game: em-dash (`—`) and middle-dot (`·`) in `SetText`, `AddLine`, and `ns.Print` calls across UI.lua and Backup.lua replaced with ASCII equivalents.
- Fixed the wish list "Done" button displaying `←` and `—` as blank boxes; replaced with `<<` and `-`.
**WishListData.lua**
- Fixed item IDs and boss assignments for Zul'Aman, Hyjal Summit, Black Temple, and Sunwell Plateau. Nearly all entries across these four raids had incorrect item IDs, wrong item names, or were assigned to the wrong boss. All four raids have been rewritten with correct Wowhead-verified data including tier token assignments (Azgalor = T6 gloves, Archimonde = T6 helms, Mother Shahraz = T6 shoulders, Illidari Council = T6 legs, Illidan = T6 chest, Kalecgos = SWP bracers, Brutallus = SWP belts, Felmyst = SWP boots, Eredar Twins = SWP necks, M'uru = SWP rings, Kil'jaeden = SWP chest).
---
### New Features
**Sync.lua — Tombstone System**
- History deletions are now durable across syncs. When an officer deletes a history entry, its key is recorded in a tombstone set (`db.deletedHistKeys`) with a timestamp. Full sync payloads include a `---TOMBSTONES---` section; receiving clients apply incoming tombstones after merging history, permanently removing any re-introduced entries. Tombstones expire after 90 days and are pruned on send and receive to prevent unbounded growth.
**UI.lua — Bag Roll Modifier**
- Added a configurable modifier key for clicking bag items to open the roll session prompt. Configured in Settings → Loot Distribution → Bag Roll. Options: None (disabled), Alt-click, Ctrl-click, or Alt+Ctrl-click. Replaces the previous hardcoded alt-click behavior with an explicit, user-controlled setting.
**UI.lua — Addon Hotkey Relocated**
- The keyboard shortcut for opening/closing the VibeCheck window has been moved from the Loot Distribution tab to the Sync & Data tab, which is a more appropriate home for a UI preference that is not related to loot settings.
**UI.lua — Free Roll Auto-Check for 10-Man**
- The Free Roll checkbox in the roll-start prompt is now automatically pre-checked when you are inside a 10-man instance (Karazhan or Zul'Aman), defaulting to the no-EPGP roll mode appropriate for those raids. The checkbox remains interactive — officers can uncheck it to run a normal EPGP roll if needed.
---
### Version
- Bumped from 0.9.5 to **1.0.0** — first full public release.
---
## 0.6.0
### Bug Fixes
**Decay.lua**
- Fixed incorrect default decay day-of-week. The fallback was `2` (Monday) but the setting default in Constants is `3` (Tuesday). Decay now fires on the correct night for guilds who never changed this setting.
- Fixed incorrect default decay hour. The fallback was `6` (6 AM) but the setting default is `0` (midnight). Both defaults now match Constants.DEFAULTS.
**Core.lua / Loot.lua**
- Fixed encapsulation bug where `Core.lua` was directly writing `ns.Loot.tradeBothAccepted = false` after `TRADE_CLOSED`. The flag is now read and reset entirely inside `Loot:OnTradeClosed()`. Core just calls the method with no arguments.
**Attendance.lua**
- Fixed `benchEPMultiplier` not being bounds-checked. A value above 1.0 would award bench players more EP than raiders. The multiplier is now clamped to `[0, 1]` before use.
**Sync.lua**
- Fixed SYNCABLE_SETTINGS guard on the *receive* side. Previously, unknown keys from a newer client version could be written into settings. Settings received over sync are now validated against the explicit whitelist before being applied.
---
### New Features
**Sync — Alt Map Propagation**
- Full sync payloads now include an `---ALTS---` section. Alt links set by any officer will now propagate to all addon users on the next full sync. Incoming links are merged non-destructively (conflicts are skipped rather than overwritten).
**Sync — Last-Sync Status Indicator**
- The addon now tracks `Sync.lastSyncTime` and `Sync.lastSyncSender` whenever a full sync is applied.
- The Settings → Sync & Data sub-tab shows a live status line: "Last sync: 3m ago from Playername" or "Not yet synced this session."
**WishListData — Phase 3–5 Content**
- Added complete wish list item databases for all remaining TBC phases:
- **Zul'Aman** (6 bosses: Nalorakk, Akil'zon, Jan'alai, Halazzi, Hex Lord Malacrass, Zul'jin)
- **Hyjal Summit** (5 bosses: Rage Winterchill, Anetheron, Kaz'rogal, Azgalor, Archimonde) — includes all T6 token slots
- **Black Temple** (9 bosses: Naj'entus through Illidan) — includes both Warglaives
- **Sunwell Plateau** (6 bosses: Kalecgos through Kil'jaeden) — includes Thori'dal
**Constants — Zul'Aman Boss List**
- Added all 6 Zul'Aman NPC IDs to `BOSS_LIST` for completeness, consistent with the existing Karazhan entries. Kill detection is still filtered to 20+ man raids so no EP will auto-award from ZA — the entries are informational.
**Settings — CSV Export**
- The "Export CSV" button in Settings → Sync & Data now opens an in-game copy dialog with the full roster CSV (sorted alphabetically), instead of dumping each line to chat. Copy and paste directly into a spreadsheet.
---
### Removals (approved)
**Slash Commands**
- Removed `/vcl test roll` — seeded fake roll sessions for UI testing. No longer needed now that the roll window is stable.
- Removed `/vcl test raid` — injected fake Gruul + Magtheridon raids. Removed alongside the 140-line injection block in `Slash.lua`.
- Removed `/vcl export` — chat-line CSV dump. Replaced by the in-game copy dialog in the Settings tab.
**Loot.lua**
- Removed `StartTestRoll()`, `TEST_ITEMS`, and `FAKE_RAIDERS` (~90 lines of test scaffolding).
---
### Performance / Quality
**Sync — History Cap**
- `HISTORY_SYNC_MAX` raised from 200 to 500 entries in outbound FULL payloads.
- Local post-merge history cap raised from `HISTORY_SYNC_MAX * 2` (400) to a fixed 1000 entries, matching the UI's display limit.
---
## 0.5.0
- Standings tab: complete rewrite with role filter pills (All / Tank / Healer / Melee / Ranged), PR rank column, attendance color coding, and Shift+Click double-sort on any column.
- Log tab: Raids sub-tab with zone filter pills, duration column, and pulsing "In progress" row. History sub-tab with date-range filter (All / Today / Week / Month), clickable player name filter, and totals footer.
- Fixed inline search-bar clear button (X) misalignment in Standings.
- Fixed "0 PR" filter button labels — now "Show 0 PR" / "Hide 0 PR".
- Standings sort now defaults to PR descending. Shift+Click any column header to set a secondary sort key.
- Fixed Unicode characters rendering as blank squares in-game. All rendered strings now use ASCII-only substitutes.
## 0.4.x and earlier
See git history.

