LibGraph-2.0-v2.0.7
What's new
Lib: Graph-2.0
[v2.0.7] (2026-08-25) — The realtime graph dropped only half its stale samples
New — the standalone addon now tells you when it is out of date
All six TOCs declare ## Dependencies: Ace3, VersionCheck-1.0, .pkgmeta lists both under
required-dependencies so CurseForge installs them alongside this addon, and the library registers
itself with VersionCheck-1.0 — which runs
one batched guild-wide check at login and pops a single alert if a guildmate is on a newer copy.
Ace3 is there because VersionCheck needs its AceComm and AceSerializer and does not embed them, not
because any drawing code uses Ace3.
Those two lists have to agree and nothing checks that they do: the TOC line decides whether the
addon loads, the .pkgmeta line decides what gets installed. A slug in one and not the
other is a dependency the player never receives, or an addon that silently refuses to load.
An embedded copy does not register, and that is the load-bearing half. This file ships inside
other addons' Libs folders, and a copy loaded from there is not an addon the client holds metadata
for — it would report its version as unknown, and could not be updated on its own even if the report
were right. So registration is gated on GetAddOnMetadata returning a real version for an addon
folder actually named LibGraph-2.0, which is true of the standalone install and nothing else. A
working copy straight from git is skipped too, since its version is still the packager's
LibGraph-2.0-v2.0.7 placeholder and broadcasting that would be worse than staying quiet.
Embedding is unaffected. An embedded copy never loads this TOC, so it pulls in neither Ace3 nor VersionCheck, and the drawing code still depends on nothing but LibStub. There is no host-side call to add.
Six specs cover it, and three of them assert that nothing happens — removing the gate turns exactly those three red.
Fixed — five prune loops removed from a table while iterating it
OnUpdateGraphRealtime ages old samples out of self.Data in five places — SLOW/RECT, SLOW/TRI,
FAST/RECT, EXP and EXPFAST — and every one of them did it as for k, v in pairs(self.Data) do … tremove(self.Data, k) … end. tremove shifts every later element down one while the iterator
counts up, so the element that slides into the vacated slot is never visited.
This is not undefined behaviour, and the distinction is the whole explanation. The Lua 5.1
manual makes next undefined only if you assign to a non-existent field, and then says: "You may
however modify existing fields. In particular, you may clear existing fields." tremove shifts
existing fields and clears the last, so the traversal was explicitly permitted. What it is instead
is a deterministic shift-versus-increment race — which is why the loss is exactly half rather
than arbitrary. Undefined behaviour has no signature; this one does.
Measured, with a backlog of six stale samples: three survived. Exactly half, in SLOW/RECT, SLOW/TRI and EXPFAST. EXP escaped only because its outer per-bar loop re-runs the prune enough times to clear up after itself.
All five now walk the table backwards with a numeric for, so a removal only affects indices
already passed. Every accumulation in those loops is a sum, so visiting in reverse changes nothing
else.
The consequence in game was never a crash: self.Data only grows while a feed is live, and a
leaked sample keeps contributing to the convolution — so a busy realtime graph plausibly read a
little high and held values a little too long. That part is not demonstrated and is not claimed.
Every prune path was already covered and already green. The four existing specs each dropped exactly one sample, which cannot expose this — there is no following element to skip. Coverage said the lines ran; it could not say an assertion would notice the traversal going wrong. Four new specs drive the bulk case and go red without the fix.
Changed — four loops that needed an order were asking for one that is not promised
Four pairs loops iterate sequences where the order is the meaning, not a convenience:
AddPieandCompletePiewalk the slice textures halving the piece size each step, so they are only correct largest-piece-first.RefreshLineGraphchains a segment from each point to the next, for both line and filled series — the sequence order is the graph.
The Lua 5.1 manual is explicit: "The order in which the indices are enumerated is not specified,
even for numeric indices. (To traverse a table in numeric order, use a numerical for or the
ipairs function.)" That "even for numeric indices" is the sentence that removes the it-is-a-plain-
array defence, and the parenthesis names the remedy. pairs walks an array part in order in
practice, which is why this has always drawn correctly — the code was relying on an implementation
detail to draw a graph. All four are now ipairs, which states the requirement.
New — a sparse data series is diagnosed at ingest instead of drawn short
AddDataSeries stores the caller's points table by reference and never checked it was dense, so a
consumer building points with explicit indices, or from a filtered source that skips entries, could
hand the library a table with a hole. Neither drawing behaviour was a diagnosis: the old unordered
walk drew every point chained arbitrarily — a visible scribble — and the ordered walk draws a
clean, plausible, shorter line, which is worse, because the graph looks correct and is missing
data.
Both entry points now compare a counted walk against the length and warn, naming which call was
given the bad table, before storing it — once per graph per entry point, not once per call.
AddDataSeries is half the reset-then-re-add refresh idiom, so a chart redrawing on a timer would
otherwise produce one chat line per refresh, forever, naming a function the player never called. It
is deliberately not re-armed by ResetData, because that reset is the refresh cycle.
It warns rather than refuses because refusing would not fix anything. The rendering outcome for
a sparse series is truncation either way — the ordered walk stops at the hole whatever
AddDataSeries decided earlier. So the choice was never refuse-or-truncate, it was truncate
loudly or truncate silently; a refusal would replace a quiet wrong picture with a hard error and
still not draw the missing points.
Raised in review, which also corrected the spec that pinned truncation as the correct rendering of a sparse series. It is the chosen behaviour for malformed input, and the spec now says so.
The first version of this check was blind on the one form real consumers use, and that is the
fourth time the same mistake has been found here. Both entry points accept two shapes: a list of
{x, y} pairs, and two parallel arrays ({times, values}, the n2 form). On the n2 form the
library rebuilds the series by walking points[1] with ipairs — so the table it hands on is
dense by construction whatever went in, and the tail past a hole is dropped on that line. The check
inspected the rebuilt table, so it could never fire there however sparse the caller's data was. Both
entry points now check points[1] before the rebuild, and the message names which array it is.
This matters because n2 is not the exotic form. Reading the two live consumers on this machine:
Details never calls AddDataSeries at all — its framework draws with DrawLine directly
(Details/Libs/DF/panel.lua:2916) — and Recount passes n2 at all four of its call sites
(GUI_Graph.lua:497, :571; GUI_CompareGraph.lua:313, :403). So the diagnostic was armed only
on the shape nobody passes. Neither addon builds a sparse table — Recount's arrays are appended
densely and decimated with table.remove in lockstep (GUI_Graph.lua:73-146, :148-192) — so this
is a latent gap rather than an observed failure, and that is all it is claimed to be.
Four specs, none of which the suite had: every existing sparse spec drove the list-of-pairs form. Removing the fix turns the two warning specs red and leaves the other 260 green — including the one that pins the truncation itself, which is the cost the warning reports and is present either way.
Fixed — two defects in DrawBar, both reachable only through the public drawing primitive
DrawBar is reached two ways, and self means something different on each: internally it is called
as self:DrawBar(self, …) where self is the graph, and publicly as lib:DrawBar(frame, …) — the
documented primitive — where self is the library table shared by every graph in the install.
The redraw request went to the wrong object. When a bar's level would bury the graph's text, the
library lifts the text frame and asks for a re-render. That request was addressed to self, so on
the public path it set a field on the shared library table that nothing anywhere reads: the lift
happened and the re-render never came. It now asks the frame being drawn into, which is the same
object on both paths.
A recycled bar kept the parent of whoever used it last. The pool returns a bar with Hide()
alone and never restores its parent, while the parent was only ever set when a level was given. So a
levelled bar, once recycled, would be drawn by an unlevelled call at the level the previous caller
happened to use — a wrong frame level chosen by pool order. Bars now return to the frame they were
drawn into when no level is asked for.
Neither is reachable from inside the library, which always passes a level — so no existing graph renders differently. Both are pinned by specs that go red without the fix.
Changed — feeding a hidden realtime graph is now a documented hazard instead of an undocumented one
AddTimeData appends one table per sample and bounds nothing. The only code that ever removes an
entry is the prune inside the graph's own per-frame update — and the client does not run OnUpdate
on a hidden frame. So a consumer that keeps feeding a hidden realtime graph allocates for as
long as the feed runs, with nothing able to reclaim it: the graph that would prune is the graph that
is not ticking.
Recount, the realtime consumer this was checked against, is safe by its own discipline rather than by anything the library does — it unregisters its tracking callback when a realtime window closes, so the feed stops with the window. That is a consumer holding an invariant the library never stated.
No cap was added, for the same reason the SetBorderSize sign was left alone: a bound would
silently change what every existing consumer's graph draws, and winning LibStub's race means they
cannot decline that by shipping their own copy. The invariant is now written on the function, in the
API documentation, and pinned by a spec that drives 500 samples through a graph that never updates
and finds all 500 still there.
Distinct from, and adjacent to, the idle-prune behaviour raised in review: that one is bounded and self-healing — nothing new arrives, and the first sample after a silence prunes normally — so it is deliberately unchanged.
Changed — SetBorderSize's sign is now documented where a developer will actually hit it
SetBorderSize(edge, size) replaces the automatic 10% margin on one edge, and every edge is applied
as extreme + size. The default it replaces is not symmetric — XMin and YMin subtract while
XMax and YMax add — so a positive size widens the axis at the top and right and moves it
inward, cropping the data, at the bottom and left. The same parameter means opposite things on
opposite edges.
The behaviour is unchanged and will stay unchanged. It is upstream's, and the +2000 band is
what makes leaving it correct rather than merely convenient: winning LibStub's race in every install
removes every other consumer's ability to decline a behaviour change by shipping their own copy. A
fork that wins every race owes the field bug-for-bug compatibility on everything except the bug it
forked to fix.
What was wrong was that the rule was written down in the CurseForge store listing — read by players
deciding whether to install, and by nobody integrating the library — while the function itself said
nothing, and its usage assert named the four edges and was silent on sign. A developer who passes
10 for LEFT, sees their data cropped and goes looking would read the function, then the error, and
find the explanation in neither. The rule is now in a comment on the function, and the assert says
that LEFT and BOTTOM need a negative size.
New — tools/check-minor-band.lua, because the spec that "pinned" the band could never fail
The suite asserted that this copy's minor beats every other LibGraph-2.0 installed. It did that by
comparing a constant against three hardcoded constants — the copies measured on 2026-08-25 — so
it could not fail, could not notice anything, and would pass forever. Its name claimed to watch a
live, changing fact; its assertion was a claim about one day.
That gap has teeth: if another addon's private fork is bumped above ours, LibStub hands it the load
race, this library's body never runs, and nothing reports it — the losing copy bails silently at
if not lib then return end. The suite would have stayed entirely green.
The spec is now named for what it actually witnesses (the decision, and the date it was measured),
and the live question moved to a tool that reads the real AddOns tree, parses every copy's minor,
and exits non-zero if anything ties or beats us. Run it before a release — that is when the
answer matters and when somebody is present to act on it. It is deliberately not a spec: a suite
that read the installed AddOns tree would fail on a machine without these addons and pass vacuously
everywhere else.
Both raised in review.
Fixed — the pie-chart hover used the wrong scale, and nothing could have noticed
PieChart_OnUpdate converts the cursor from screen space with GetEffectiveScale, which is right —
but every spec fed the cursor through a helper that multiplied by the same getter the code divides
by. The term cancelled, so swapping it for GetScale, or dropping the conversion entirely, left
every assertion passing while the cursor and the pie disagreed by the scale factor in game.
Two specs now put the scale on an ancestor, so GetScale and GetEffectiveScale genuinely
differ, and supply it to the cursor as a literal. Verified by making the one-word change the review
described: both go red, every other pie-chart assertion stays green.
Fixed — the pie chart's k == 7 was #PiePieces written as a literal
AddPie set a boundary-line fudge on the last slice by comparing the loop index against 7, which
is the length of the slice table spelled out. Add a "1-256" slice — the obvious way to increase
pie resolution — and it fires one iteration early; remove one and it never fires, so the boundary
line is drawn slightly short on every full pie. Neither case errors and neither is visible in a
percentage: the arithmetic stays self-consistent and only the picture is wrong. Now #PiePieces.
Raised in review as surviving the pairs → ipairs change untouched, which it did — ipairs
yields the same integer keys, so the fix next door would have left it sitting there. A new spec
drives AddPie and CompletePie over the same percentage and requires the same decomposition,
because they are line-for-line the same loop and nothing asserted they stayed in step.
No behaviour change on any well-formed input, and the suite is identical either way, which is the point: nothing would have caught someone changing it back. One real difference, now pinned by a spec: a caller passing a sparse points table gets a line truncated at the hole rather than segments joined in arbitrary order. Truncated-but-coherent is the better failure.
The other eighteen pairs loops in the file were checked and left alone — they set a property on
each element, or accumulate a sum, and order genuinely does not matter.
[v2.0.6] (2026-08-25) — One shared copy: the FastGuildInvite and Recount forks merged, and the Retail 12.0 crash fixed
LibGraph-2.0 had been carried around as a private copy inside every addon that used it, and the copies had drifted. This release is the point where they stop drifting: one repository, merged from the two forks that had real changes in them, published so consumers can embed the same file.
Fixed — 148 identical errors per session on Retail 12.0
GraphFunctions:PieChart_OnUpdate tested the mouse with the MouseIsOver global. Retail 12.0 no
longer defines it, and this file caches its globals as upvalues at load time — so the upvalue was
nil for the entire session and every frame the pie chart updated threw. One sitting produced 148
copies of the same error.
The call is now self:IsMouseOver(). That is not a workaround: the global was only ever a one-line
forwarder to this widget method (Blizzard_UIParent/Shared/UIParent.lua:530), and the method is a
SimpleScriptRegion member documented in Classic Era's own API tree as well as Retail's. It is
correct on every flavour and needs no version guard, so the local MouseIsOver = MouseIsOver
upvalue is gone rather than made conditional.
Fixed — DrawHLine drew its texture upside down
The FastGuildInvite copy had replaced the 8-parameter SetTexCoord(0,0, 0,1, 1,0, 1,1) in
lib:DrawHLine with the 4-parameter SetTexCoord(0, 1, 1, 0), described in its own comment as the
simpler equivalent. It is not equivalent. The 4-parameter argument order is
(left, right, bottom, top) — per SimpleTextureBaseAPIDocumentation.lua:395 — so that call swaps
the last two and draws sline vertically flipped.
It is now SetTexCoord(0, 1, 0, 1), which is the true 4-parameter equivalent of the 8-parameter
identity mapping the original used. This keeps the simplification the fork was after without the
flip. In fairness this may never have been visible: if sline.tga is symmetric top to bottom, both
forms render identically. It was corrected because a behaviour difference nobody intended is worth
removing whether or not anyone could see it.
New — graph.LabelHook
Set graph.LabelHook to a function and it is called with each axis-label FontString immediately
after the label's text is set, before it is shown. It exists so a consumer can localise the
library's internal axis labels — substituting a locale's own digits, or applying a font that can
draw them — without the library needing to know anything about localisation.
graph.LabelHook = function(fontString)
fontString:SetText(MyLocale:Digits(fontString:GetText()))
fontString:SetFont(MyLocale:FontPath(), 10)
end
It is called at all five label sites: both Y-axis label passes, the X-axis bottom labels, and the
two remaining axis labels. Leave it nil and nothing changes.
New — graph.XLabelsEnabled
Set graph.XLabelsEnabled to true and a line graph draws numeric labels along the bottom, under
each X gridline, following the same secondary-gridline spacing the gridlines themselves use. The
rightmost label is skipped so it cannot run off the edge of the frame. Off by default; the original
library drew no X-axis labels at all.
Changed — the library version is bumped +2000 over stock, and +1000 was not enough
minor is 90000 + 2000 + <SVN revision> rather than stock's 90000 + <SVN revision>. Every
consumer embeds its own copy of LibGraph-2.0 and LibStub loads whichever registers the highest
minor, so without a bump an addon shipping a stock copy would win the load race and everything
above would silently not exist.
The band is +2000 because +1000 tied, and a tie loses. LibStub:NewLibrary refuses an equal
minor — if oldminor and oldminor >= minor then return nil end — so the second copy to load bails
at if not lib then return end and the winner is decided by load order alone. Measured across one
install:
| Copy | Registers | Carries |
|---|---|---|
| Details | 90062 |
stock r62, the MouseIsOver crash |
| Recount | 90068 |
stock r68, the MouseIsOver crash |
| FastGuildInvite | 91068 |
a private fork: the crash and the flipped DrawHLine |
| this copy, before | 91068 |
both fixed — and tied |
fastguildinvite sorts early in load order, so on an install carrying both, the crashing copy
won and this one never registered. That is the exact outcome the bump exists to prevent, so the
bands are now explicit: +0 stock, +1000 a private per-addon fork, +2000 this shared copy,
which supersedes both. A spec asserts the minor beats each of the three measured above, and a second
one pins the equal-minor-loses rule so nobody simplifies the band back.
Existing graphs are retrofitted, so this reaches players who never update the addon that embeds
the old copy. When this file loads over a lower-minor copy it re-runs SetupGraph*Functions over
everything in lib.RegisteredGraph*, repointing already-created graphs at the new implementations.
Verified against Details' r62 copy, which carries the same registration tables and the same retrofit
loop — so a graph built by a stock copy before this one loads still gets the Retail 12.0 fix.
Changed — packaged for release
The repository now carries a TOC for each live flavour (Classic Era 11509, TBC 20506, Wrath 30405,
Cataclysm 40402, Mists 50504, and Retail 110207 / 120005 / 120007 / 120100), a .pkgmeta, and a
BigWigs packager workflow that builds on a pushed tag. It also carries a LICENSE, which it did not
before: the original work stays All Rights Reserved to Cryect and Xinhuan, maintenance from this
release onward is MIT, and LibStub.lua remains public domain.
New — an offline test suite, and the two standing review files
None of this ships: .pkgmeta excludes Tests and docs, and everything else added here is a
dotfile or CLAUDE.md, both of which the packager prunes on its own.
The repository now carries the shared WoWAPITesting
harness as a git submodule at Tests/wowapi (pinned 1f8fe09), with six spec files covering the
library end to end: the load-time contract and LibStub load race, the debugstack-derived texture
directory, the data-series shape sniff, the axis and lock setters, LinearRegression, the rotated-
texture drawing primitives and their object pools, gridlines and both label hooks, the line and
scatter refresh paths, all five realtime convolution modes, the pie chart including its hover
surface, and the shipped TestGraph2Lib demo. 242 examples, 0 failed.
Line coverage of LibGraph-2.0/LibGraph-2.0.lua is a measured 98.05% (1461/1490). The 29
uncovered lines are three upstream blocks with no production entry point —
GraphFunctions:SetAutoscaleYAxis (its assignment is commented out), the non-realtime
SetBarColors (shadowed by RealtimeSetColors), and TestRealtimeGraphRaw (nothing calls it).
They are left in place because this file is kept close to upstream so the three-way diff against the
forks stays readable, and docs/AUDIT.md records them rather than rounding the number up.
Run it from the repo root with a Lua 5.1 interpreter and nothing else:
lua Tests/wowapi/run.lua
Fixed — FindFontString raised on a graph that had not drawn gridlines yet
GraphFunctions:FindFontString iterates self.FontStrings, and nothing constructs that table —
only HideFontStrings creates it. CreateGridlines calls HideFontStrings first, which is the
only reason this has never been seen in game; a consumer calling the public FindFontString
directly on a fresh line or scatter graph got bad argument #1 to 'pairs' (table expected, got nil). It now carries the same guard HideFontStrings has. Found by the new spec suite, not by a
bug report.
Two append-only files come with it. docs/AUDIT.md is where a peer-review session writes findings
against this library and where we answer them; Tests/HARNESS_CONTRACT.md is where we ask the
harness for something it does not model yet. One request went in there immediately —
StatusBar:SetOrientation, which Classic Era documents and the harness gives only to Slider, and
without which no realtime graph can be constructed offline at all. It came back DELIVERED the same
day but not yet pushed, so Tests/env_local.lua stays: it wraps CreateFrame and fills the two
methods only where they are absent, so it yields on its own the moment the real implementation
arrives in a pin we can move to.
Removed — a changelog paragraph that had got into LICENSE
Section 2's MIT grant carried a paragraph listing this release's changes. A licence states terms and scope; what changed belongs here. The grant is unaffected — its scope is still the section heading, "Modifications from 2026-08-25 onward".
Provenance: this is GraphLib / LibGraph-2.0 by Cryect and Xinhuan, SVN r68, as vendored in FastGuildInvite and Recount. Version 2.0.5 and earlier predate this repository and have no changelog entries here.
This mod has no additional files

