All notable changes are documented here. Format — Keep a Changelog, versioning — SemVer.
Reset() from
PLAYER_ENTERING_WORLD (while IsBattleground() was still false during world
load) killed both auto-scan channels for the rest of the match. Added a 15-second
grace gate from PVP_MATCH_ACTIVE: a false "left the BG" in the opening seconds no
longer resets state. Committed 03ea999, verified in-game 2026-06-18. (0.9.21 not
cut/published yet — the fix lives only in source.)Database.lua, new
UploadState.lua). The Uploader writes a file into the addon folder with a
cursor saying "samples are server-confirmed up to point X" (per account, by
installId), and on world-enter the addon deletes exactly the confirmed
samples (PruneUploaded) instead of guessing by age. The 0.9.19 heuristic
(30-day window + cap of 4000) stays as a backstop for when there is no
cursor / it's stale. Safety: with a missing/mismatched installId, or a
stale, lowered or future cursor, the addon deletes less, never losing
un-uploaded data; your own character is untouched; samples without endedAt
are kept. The cursor is monotonic (lastSamplesUploadedThrough) and clamped
against future values. The first handshake needs one /reload cycle (the
addon generates installId, the Uploader sees it after SavedVariables are
flushed to disk).installId (Database.lua): the code called
math.randomseed, which is absent from the WoW sandbox, and errored out.
Fixed (shipped in the published 0.9.20 zip).Database.lua). The
players table kept every player ever seen in an Epic BG forever (in
production: 29,587 entries, PremadeIQ.lua ≈46 MB), which made WoW slow to
write it on logout (slow close) and slow to load on login. It now keeps a
rolling window: players not seen for more than 30 days are evicted, and the
total is capped at 4000 (LRU by lastSeen); your own character is untouched.
The sweep runs on world-enter (once UnitGUID("player") is known) and after
each match. This data is only needed for the Uploader — in-game the premade
alert reads KnownPremades.lua, not this table. rawStats is left alone
(deferred to a follow-up). Marker PremadeIQ_DB.pruneVersion, one diagnostic
line in collectorLog.## Interface bumped 120000 → 120005 for the current live client 12.0.5
(build 67823) — no "out of date" flag in addon managers.LICENSE (All Rights Reserved) bundled; vendored libraries keep their own
licenses.Locales.lua). The premade call-out is pasted into the
English-facing community (/rw, Discord), so the copy flow (button, its
tooltip, the PremadeCopyTip/PremadeCopyHint/PremadeCopyNone hints) is
fixed to English regardless of client language. The premade alert itself
(PremadeDetected, etc.) stays localized.docs/ebg-map-filter-plan.md, externally reviewed). Whitelisted instance map
ids (ns.EBG_INSTANCE_IDS — a mirror of the server's EBG_MAP_NAMES):
Alterac, Isle of Conquest, Ashran, Wintergrasp, Korrak's Revenge, Deephaul
Ravine. Arenas / normal BGs / blitz are no longer written to SavedVariables
or sent to the server (previously ~17% of uploads were quarantined as
non_ebg_map). The gate is three-layered: PVP_MATCH_ACTIVE (Deserter/
PremadeAlert don't start), UPDATE_BATTLEFIELD_SCORE (stale windows from a past
Epic don't scan the wrong scoreboard), PVP_MATCH_COMPLETE + a hard guard in
SnapshotMatch itself (which also covers manual /piq snapshot).PremadeIQ_DB.collectorLog (50 lines) — gate
decisions are recoverable from SavedVariables.GetInstanceInfo() no longer returned an instance id at snapshot time
(teleport to a capital), the code substituted UiMapID from
C_Map.GetBestMapForUnit — a different id space the server whitelist doesn't
know → ~3% of real Epics (30 matches in production) were quarantined with a
map_name like "Silvermoon". The instance id is now captured on
PVP_MATCH_ACTIVE and used as the fallback; the UiMapID fallback was removed./piq version and the load line showed a hardcoded 0.9.2 — the version is
now read from the toc (C_AddOns.GetAddOnMetadata).KnownPremades.lua is GOD-tier data
that the owner's Uploader rewrites hourly; it was leaking into the public
archive and bypassing the server's tier-gating. The zip now always ships a
neutral stub (leaders = {}), plus a hard guard that fails the build if
anything but the stub slips into the archive. Each user gets the catalog their
own tier earns, only through their own Uploader.60, but after the move to raid tokens (≤40 in an Epic) it became
unreachable, so the event re-capture spun the full 30s for nothing. The
threshold is now the current group size (GetNumGroupMembers); the enemy
scoreboard is an optional bonus.SetBattlefieldScoreFaction(-1) in Collector.lua). RequestBattlefieldScoreData
alone doesn't clear it — without this ~1% of matches arrived single-faction./rw, read by a mixed-language BG — English as the
lingua franca, same logic as the server. Previously it was Russian on a
Russian client./piq
premade check: the BG enter/leave events didn't fire and the button lingered).
In normal combat it still hides on leaving the BG. Tooltip updated: left-click
copy, right-click hide, drag to move./rw or Discord).
Previously you had to type /piq copy. The button lives in the main addon
(which everyone downloads), not in the owner-only PremadeIQ_Leader toolbar —
otherwise only the owner would get the feature. It's a button, not an
auto-popup: the dialog focuses its input field, and auto-popping in combat
would steal WASD; the click opens it at the right moment. The button is
draggable, its position is saved (PremadeIQ_DB.copyBtnPos), and it hides on
leaving combat.CONFIRM_MIN_MEMBERS = 6) nearby = "PREMADE" (red). Lone members without a
leader are no longer flagged.alertLog diagnostic line gained a ldr field (number of
leaders on the enemy side).UPDATE_BATTLEFIELD_SCORE on an
80-player Epic fires many times a second, and on each one we scanned the whole
scoreboard AND called SetBattlefieldScoreFaction(-1), which itself fires a
synchronous score update reprocessed by the whole UI (a heavy rebuild of
Blizzard's native 80-player scoreboard) and by addons — a per-frame storm.
Added throttling: autoScan runs at most once per SCAN_THROTTLE_SEC = 1.5
(lastScanAt/GetTime, reset in OnMatchActive/Reset). One scan per ~1.5s
is plenty to catch a roster that loads over seconds; manual /piq premade
ignores the throttle.alertLog, even our own
half grew 16→35 over ~50s). The old 60s window / timers up to 45s could end
before the enemy half appeared. Timers are now
{4,8,14,22,32,45,60,80,105,135,170} with SCAN_WINDOW_SEC = 180. The
announcement is one-shot (fired), so on a hit the scans stop immediately —
the long window only costs anything when there's no premade. Names are
NeverSecret, so reading deeper into the match is safe.detect() called SetBattlefieldScoreFaction() with no argument (nil), but
nil does NOT show both factions — alertLog proved it: n stuck at ≈35 (own
team only), enemy=0, even though the server confirmed enemy premades in most
matches. The "All" tab in Blizzard's native scoreboard passes factionEnum =
-1 (PVPMatchResults.xml; 1 = Alliance, 0 = Horde). We now call
SetBattlefieldScoreFaction(-1) — and on every scan, since calling it on an
early n=0 tick didn't "stick". The 0.9.6 re-entrancy guard makes the repeated
call safe; the factionUnfiltered flag was removed.SetBattlefieldScoreFaction(),
added to detect(), fires UPDATE_BATTLEFIELD_SCORE synchronously; our
OnBattlefieldScoreUpdate handler → autoScan → detect() re-entered, and so
on until the C stack overflowed (RequestBattlefieldScoreData was async, which
is why it didn't recurse before). Added a re-entrancy guard: detect() is now
a wrapper over detectImpl with a scanning flag (an inner re-entry is a
no-op) and pcall (the flag is always reset). Plus
SetBattlefieldScoreFaction is called at most once per match
(factionUnfiltered, reset in OnMatchActive/Reset) — the filter is
persistent anyway, no need to poke the event on every scan.RaidWarningFrame
(RaidNotice_AddMessage, like the native /rw) — so it isn't missed in Epic
spam. The new /piq copy command opens a dialog with a ready-to-paste
line ("Premade on the enemy team! — Leaders: … — Members: …"), text selected,
press Ctrl+C and paste into /rw or raid chat yourself. Chat from insecure
code is blocked in 12.0.x (ADDON_ACTION_BLOCKED), so copying is manual; the
dialog doesn't auto-popup (it won't hijack WASD in combat) and is opened by the
command instead. After every alert a /piq copy hint is added to chat.alertLog gave enemy=0. Root cause:
GetScoreInfo/GetNumBattlefieldScores return rows only for the faction last
selected by SetBattlefieldScoreFaction; the default after
PVP_MATCH_ACTIVE is the local faction, so the enemy half (exactly what we're
looking for) is invisible. In the log this is n≈38 on misses vs n≈75 in
the one match that fired. detect() now calls SetBattlefieldScoreFaction()
(= both factions) before reading — the same call Blizzard's native scoreboard
makes when switching team tabs (PVPMatchScoreboard.lua).alertLog diagnostics found the root cause: in live Epics
C_PvP.GetScoreInfo(i).guid arrives as a secret value already in the opening
window (str=0 sec=77 hit=0), and detect() looked up only lookup[guid] →
skipping every row. The name, faction, className, raceName fields of
the PVPScoreInfo struct are marked NeverSecret = true
(Blizzard_APIDocumentationGenerated/PvpInfoDocumentation.lua), i.e. they stay
plain even in an active match. detect() now matches the enemy roster by
normalized Name-Realm (guid only as an exact shortcut while it's plain), and
determines the enemy side by faction. ensureLookup builds a second
name→entry index; normName appends the viewer's realm to bare same-realm
names. Dedup by the catalog entry's name. Taint-safe: secret values are never
compared.alertLog line now carries an nm field (number of plain names on the
scoreboard) alongside str/sec — to confirm name readability within one
match.PremadeAlert writes a ring
log to PremadeIQ_DB.alertLog (100 lines): on PVP_MATCH_ACTIVE — catalog
state (catalog_leaders, lookup, tier); on each scan — bg / cat / n /
str / sec / hit / enemy / mine (in a BG, catalog size, scoreboard rows,
readable guids vs secret, catalog hits, enemies, own faction); and ANNOUNCE
when it fires. Lines are deduplicated. With /piq debug on they're mirrored to
chat in grey. The goal: diagnose "the alert doesn't fire" from an on-disk dump,
without relying on reading chat live.detect() skipped them) → a miss
every time. PremadeAlert now catches the UPDATE_BATTLEFIELD_SCORE event and
re-scans on every update within a 60s window (the accumulating strategy that
makes Deserter reliable), plus the timers were extended to
{4,8,14,22,32,45}. One announcement per match is preserved./piq now prints a catalog status line (loaded / not loaded, how many
leaders, tier) — you can check the alert feed any time without waiting for the
login line. New localized string PremadeCatalogMissing.PremadeAlert.lua: on PVP_MATCH_ACTIVE it starts a multi-shot
roster capture (4/8/14s — the early window where names/guids still read
plain, like Deserter; on a deep match they're secret, but we don't go
there). It checks the enemy side (faction ≠ ours → also catches
mercenaries) against the catalog. Every read is guarded by issecretvalue.GET /api/addon/known-premades) and writes to KnownPremades.lua; the
addon loads it on login//reload.KnownPremades.lua dictates
what to show (has members → roster, otherwise leader only); there's no tier
logic in the addon. You can't fake an upgrade by editing the file — the
server doesn't put what your tier hasn't earned into the file./piq premade — manually check the current match.KnownPremades.lua — a shipped stub, overwritten by the Uploader. A
toc-data file (not a SavedVariable): WoW loads it at startup and never writes
to it, so the Uploader's update is never clobbered by the client.GET /api/addon/known-premades (tier-gated, catalog.py with a 30-min
TTL cache). Builds leaders from enemy_leaders + co-players (≥3 shared clean
matches on the home side) for patrons. +1 test (test_addon_catalog).ApiClient.fetch_catalog() + catalog_writer.py +
resolve_addon_dir, writes the file into the addon folder once an hour after
upload. +6 tests (test_catalog_writer).--standalone).MID_SNAPSHOT_INTERVAL = 0). An empirical
check on a real match (retail 12.0.x): not only the numeric fields
(damageDone, etc.) are protected as secret values, but also info.guid —
and the Lua serializer silently drops secret strings from SavedVariables. The
result: snapshots arrived at the server with no guid and zeroed metrics,
useless for off-board metric recovery. Worse — without a guid the server
rejected the whole upload as a 422 validation error, and a backlog of matches
got stuck.The snapshot code is left in place: once we find an alternative source (combat log parser or a secure-context hook), we just restore a non-zero interval.
pvpStatValue, damageDone, healingDone, …) during an
active match — arithmetic on them crashed TakeMidSnapshot() with "attempt to
perform arithmetic on a secret number value". The final snapshot from
PVP_MATCH_COMPLETE survived this because Blizzard drops the protection on the
completion event; the mid-snapshot runs mid-match and hit the guard. Every
numeric read is now wrapped in a safeNum() helper that returns 0 for
protected values (via issecretvalue) instead of crashing. Some snapshot
fields will be zero (the protected ones), but the snapshot is saved and at
least guid+faction signals the player's presence on that tick.Collector:TakeMidSnapshot() via
C_Timer.NewTicker periodically snapshots the scoreboard during the match.
Saved as snapshots = {{takenAt, players = [...]}, ...} inside a matchLog
entry. This lets us recover the metrics of players who left before the end and
were substituted — they're no longer in Blizzard's final scoreboard (always 40
per side).snapshots field added to matchLog entries. Old v2 entries
without snapshots are fine — the server treats absence as "no timeline
available for this match".In long Epic BGs (30–90 min stall-wars) player rotation is common. Before this
release we only saw the 40 who finished the match; those who left earlier had
only a name in the deserters table, with no dmg/heal/etc. Now we have data
points every 5 minutes, and an off-board deserter usually lands in at least one
snapshot.
findRaidLeaderGUID() in Collector.lua iterates GetRaidRosterInfo for
rank=2 (raid) or UnitIsGroupLeader (party) and stashes the GUID on the match
payload as leaderGUID. The server stores it on Match.leader_guid and
recomputes per-player leader_count like desertion_count is recomputed.Libs.xml and .toc load list accordingly.
LibSerialize and LibDeflate are kept — they may be useful later, and sit as
one LibStub module load each with no active work.Deserter.lua
(~80 lines) does a single replay-of-the-data trick: at +6s after
PVP_MATCH_ACTIVE it snapshots the scoreboard — who came in to fight; the
final scoreboard on PVP_MATCH_COMPLETE is already taken by Collector. On
ingest the server diffs the sets and, for each "present at start / absent at
end", checks faction vs match.winner — if the faction lost, it flags the
player as a deserter.C_PvP.GetActiveMatchDuration() > 60s at
PVP_MATCH_ACTIVE, we set lateJoin=true and the server skips the diff for
that match (so we don't false-positive everyone who left before we joined).startRoster: { lateJoin, takenAt, players: [{guid,
name, faction}] } field. The server computes deserters; in the DB they go to a
deserters table with a natural key (reporter_id, ended_at, guid) for
dedup across reports.Main.lua — we make do with the same
PVP_MATCH_ACTIVE / PVP_MATCH_COMPLETE / PLAYER_ENTERING_WORLD /
UPDATE_BATTLEFIELD_SCORE. No chat parsing, no locales, no debuff watching.Collector:ScheduleSnapshotMatch:
two RequestBattlefieldScoreData() calls 0.7s apart guarantee both sides are
cached before iterating. Total delay before the snapshot is 1.4s (was 1.0).
The same logic applies to the manual /piq snapshot command./piq options crashed with bad argument #1 to 'OpenSettingsPanel' (outside
of expected range). The code set category.ID = "PremadeIQ", overwriting the
auto-generated numeric ID with a string; OpenSettingsPanel accepts only an
integer. Removed the manual assignment, keep the category object and pass
category:GetID().Settings.RegisterCanvasLayoutCategory (retail 11.0+)./piq options (aliases /piq config, /piq settings) — open
the panel programmatically.enrichment_status='error'. The collector now
always appends the local realm via GetRealmName() if it's missing from
info.name.info.raceName) is now shipped in every sample. The server
uses it to detect mercenaries: a match card will show "played for Alliance,
actually Horde" and vice versa./piq status | uploader | snapshot |
debug on|off | reset confirm | version.version 2 + automatic migration from v1.PremadeIQ_DB.settings table for user settings (debug flag, hints).Database:GetSetting/SetSetting, Database:CountSamples.0.4.0, added IconTexture, X-Website, an
X-Curse-Project-ID placeholder./piq reset now requires confirm to guard against a typo./piq debug on flag — no longer spamming
chat for normal players.PVP_MATCH_COMPLETE.select(8, GetInstanceInfo()).C_PvP.GetActiveMatchWinner instead of GetBattlefieldWinner.won = nil for lost matches.All notable Uploader changes are documented here. Format — Keep a Changelog, versioning — SemVer. Versions before 0.7.0 predate this public changelog.
/api/addon/latest manifest; it falls back to the
install page when absent.UploadState.lua file into the addon folder
with a cursor that says "samples are server-confirmed up to point X" — per
account, keyed by a stable installId. The addon uses that cursor to delete
exactly the delivered samples instead of guessing by age, so the local
database stops growing (in production: PremadeIQ.lua shrank from 46 MB to
under 1 MB → the game closes instantly). The cursor is written from the
server's actual acknowledgement, is filtered to the right install
(_retail_, not _ptr_/_beta_), and never moves on non-sample events.state.json now
survives transient file locks on Windows (antivirus / indexer; os.replace
with retry). Previously a single failure could freeze the on-disk checkpoint
for days, so after a restart the Uploader re-read and re-sent the whole
backlog. Unexpected write errors are still not masked.