promotional bannermobile promotional banner

LibItemDB

A library with all of the WoW items for use by other addons
Back to Files

ItemDB-v0.8.0

File nameItemDB-ItemDB-v0.8.0.zip
Uploader
PmptastyPmptasty
Uploaded
Sep 14, 2026
Downloads
342
Size
18.7 MB
Flavors
MoP ClassicClassic TBCClassic
File ID
8882887
Type
R
Release
Supported game versions
  • 5.5.4
  • 4.4.2
  • 3.4.5
  • 2.5.6
  • 1.15.9

What's new

Changelog

[v0.8.0] (2026-09-14) - Item VALUE: the price ladder, the Auction House scanner and the third-party price integrations move here from TOGProfessionMaster, with their own window

One body of work, at the operator's direction (2026-09-14, relayed by TOGBankClassic's LIBREQ-PRICE-001..010): "we need to move those 3rd party integrations into itemDB now and build a UI in IDB for those integrations like we do currently in TOGPM. we'll want to move that all into it's own window, we can use VersionCheck as a reference." TOGProfessionMaster carried the only Auction House scanner in the fleet plus a duplicate of the Auctionator / TSM adapters this library already published in Integrations.lua -- and TOGBankClassic's storefront needed the same capability. Two copies of a third-party integration is LIBREQ-ACQ-001 again, so it is one copy, here, consumed by both. Library MINOR 24 -> 25. Feature-gate on if DB.GetPrice then.

The boundary, stated once so it is not re-argued: the library answers "what is this item worth"; it never answers "what do we charge for it". Guild discounts, officer overrides, donation rates and rank tiers stay in the consumers. TOGPM's crafting-cost maths (CraftCost, CraftCostForReagents, GetReagentCost -- a vendor-sold reagent costed at the vendor price even when someone lists it) stay in TOGPM; they are profession policy, not market.

New Features -- Price/Sources.lua: the ladder, its store, its settings

  • DB:GetPrice(itemID, statistic) -> copper, provenance. Keyed by item id, never name or link; copper as an integer; nil for no data and never a zero (an unpriced item silently priced at 0 is given away for free -- TOGBankClassic's LIBREQ-PRICE-003). A named statistic is answered only by a source that carries it: "minBuyout", "market", "historical"; asking for market never quietly hands back a minimum buyout under another label. "best" walks each source's statistics in that source's own preference before moving to the next source -- minBuyout, market, historical for Auctionator, Auctioneer and the own scan, which is exactly the ladder TOGPM's Price.Get ran (Auctionator live then historical, Auctioneer market then cached, own scan); market, minBuyout, historical for TSM, whose market figure is the region-wide one (see the TSM source below). Provenance carries source, sourceName, the REAL statistic (never "best"), age in seconds and the matching at epoch -- the own scan is exact, Auctionator reports whole days (GetAuctionAgeByItemID), Auctioneer and TSM do not say and report nil.
  • DB:GetPrices(itemIDs, statistic) -> results, count, the bulk form: an array of ids or a set { [id] = ... } (a bank inventory's shape), the source list resolved once per batch. Pinned by a spec that counts the detection probes.
  • DB:GetPriceSources() -- every source in the user's precedence with detected, enabled, precedence and the statistics it can answer; DB:HasPriceData() -- whether ANY enabled source could answer on this machine, so a UI can tell "this item has no price" from "you have no price data at all" (LIBREQ-PRICE-006).
  • DB:GetVendorBuyPrice(itemID) -- what a vendor charges, best source first: Auctionator's vendor cache when that source is enabled, a price this account saw at a merchant (captured on MERCHANT_SHOW, per single item, unlimited-stock wares only -- the only source that knows the player's reputation discount), then the shipped base price. TOGPM's Price.GetVendorBuy, moved.
  • Four sources, one registry (SOURCES), each with detect / enabled / stats:
    • Auctionator -- minBuyout from GetAuctionPriceByItemID; historical from Auctionator.Database:GetMeanPrice(id, 14), gated by useAuctionatorHistorical. TOGPM's adapter reached for a GetHistoricalPriceByItemID that Auctionator's API v1 does not have (read: Source/API/v1/* carries no such function) and silently fell back to the live price, so its "historical" was never historical. A v1 function of that name is still preferred if one ever ships; the mean is what answers today.
    • Auctioneer (Auc-Advanced) -- market from GetMarketValue, historical from the cached stat engines (GetAlgorithmValue across the known engine names and call shapes, then the Stat modules' GetPriceArray, most observations wins), gated by useAuctioneerCached. The item is offered as the real link when cached, then item:%d:0:0:0:0:0:0:0, then item:%d -- built by appending, because a nil first element stops ipairs before the synthetic forms that exist for the uncached case. The one adapter this library did not already have.
    • TradeSkillMaster -- REGION figures first. The operator, after the first in-game pass (/itemdb price answered "No price for item 8952" with TSM enabled and first): "you need to use tsm app helper to populate the TSM regional data, that is correct ALL THE TIME FULL AH data -- not piecemeal data from the AH we're getting now -- the piecemeal stuff is for suckers WITHOUT TSM". TSM holds two pictures of the house. The realm figures (DBMinBuyout, DBMarket, DBRecent, DBHistorical; App Helper's AUCTIONDB_NON_COMMODITY_*) are only as fresh as the Desktop App's last publish for THAT realm -- Old Blanchy's carried a downloadTime of 2024-11-19, 664 days old, with most items (8952 among them) absent, and TSM serves that snapshot with no age on it. The region figures (DBRegionMarketAvg, DBRegionHistorical, DBRegionSaleAvg; App Helper's AUCTIONDB_REGION_* for Classic-US) are every realm in the region, aggregated and refreshed by the app -- 8952 is there at 20s. So: market = DBRegionMarketAvg then DBMarket / DBRecent; historical = DBRegionHistorical / DBRegionSaleAvg then DBHistorical; minBuyout = DBMinBuyout (a minimum buyout is a listing, not an aggregate; there is no region one). useTSMAppHelper gates the region half, defaults ON, and no longer enables the source by itself -- it is TSM's primary half, not a fallback, so it is NOT cascaded off with useTSM either (a source toggled off and on must come back on region data). Read through TSM_API rather than by capturing TSM_APPHELPER_LOAD_DATA: TSM already resolves the client's region dataset (Classic / HC / SoD / Fresh x US / EU), decodes the app's base-32 packing, and TradeSkillMaster_AppHelper cannot load without TSM (## Dependency), so there is nothing underneath it to reach and a load-order trap in trying. Both item-string spellings and both argument orders are still tried, as TOGPM did, because builds differ.
    • ItemDB scan -- minBuyout from the store, with an exact age.
  • Settings, per account, with TOGPM's defaults except two: own scan on, auto-scan off, delay 0 (client default), every third-party parent toggle off, every sub-toggle on so enabling a parent gets its whole ladder (TOGPM shipped useTSMAppHelper off; see the TSM source above for why it is on), order Auctionator / Auctioneer / TSM / scan -- and default statistic best, not TOGPM's minBuyout: a minimum buyout is a listing figure that Auctioneer never carries and TSM's region data never carries, so a caller naming nothing skipped both, which is what the first in-game /itemdb price showed. best lands on the same minimum buyout wherever that is all a source has. The settings table carries a version stamp (1): a table with none was written by the pre-release build that materialised the old two defaults into it, and those two values (defaultStatistic, useTSMAppHelper) are reset to the new defaults once, on load -- a saved default cannot otherwise be told from a chosen one. GetPriceSetting / SetPriceSetting (validated: booleans coerced, scanDelay clamped 0.5-10 with 0 = default, useAuctionator / useAuctioneer going off take their fallback with them), GetPriceSourceOrder / SetPriceSourceOrder / MovePriceSource (unknown ids dropped, omitted ids appended, so a saved order from an older build gains a new source rather than losing it). Every write fires LibItemDB_PriceSettingsChanged.
  • The store is scoped by realm + faction ("Realm - Faction", DB:GetPriceScope()), as TOGPM's Ace.db.factionrealm was: Vanilla and TBC keep one auction house per faction, so a Horde scan never answers an Alliance lookup (specced by swapping the faction mid-example). Derived per-item statistics only -- { p = copper, at = epoch, n = listings } -- never raw rows (LIBREQ-PRICE-008).
  • SavedVariables: LibItemDB_PriceDB, declared in all five TOCs, the first thing this library has ever persisted. A write made before ADDON_LOADED (a consumer pricing during its own load) lands in a pending table that is promoted to the saved global when the client hands us none, and discarded when it hands us a saved table -- so an early write survives and a saved table is never clobbered. Tests/toc_spec.lua asserts the directive against the name in the source.
  • Callbacks through CallbackHandler-1.0 (DB.RegisterCallback(owner, event, fn)): LibItemDB_ScanComplete(kind, reason, results), LibItemDB_ScanProgress(kind, scanned, total, item), LibItemDB_AuctionHouse(isOpen), LibItemDB_PriceSettingsChanged(key, value).
  • DB:FormatMoney(copper) (TOGPM's Price.Money), DB:FormatPriceAge(seconds), DB:GetPriceStatistics() / GetPriceStatisticLabel, DB:StoreScannedPrice / StoreVendorPrice (public so PersonalShopper's scanner can feed the same store later), DB:GetScannedItemCount(), DB:GetLastScan().

New Features -- Price/Scanner.lua: the Auction House scanner, moved

TOGPM's Modules/AHScanner.lua, ported whole: the full scan (legacy QueryAuctionItems(..., getAll=true) on Era / TBC / Wrath, honouring CanSendAuctionQuery's second return and silencing every other AUCTION_ITEM_LIST_UPDATE listener for the payload -- Auctionator's pattern; C_AuctionHouse.ReplicateItems on Cata / MoP with a 15-minute self-throttle; 500 rows per frame either way), the targeted scan (StartTargetedScan(items, opts), one browse query per name with the configured delay, CanSendAuctionQuery retry on the legacy path, an uncached-item retry and a silent-server timeout on the modern one), GetScanListings, CancelScan, the ItemDB Scan button on the Auction House frame, IsAuctionHouseOpen, and AuctionHouseSearch(name) (TOGPM's AH.SearchFor, so its [AH] buttons keep a home). GetScanState() is the LIBREQ-PRICE-005 surface: running / kind / progress / last scan / house open. Ace timers became C_Timer.After; addon:Print became a LibItemDB: chat line; the modern-vs-legacy decision is still taken once at load from C_AuctionHouse.

The arithmetic that matters is unchanged and re-pinned: per-UNIT price is ceil(buyout / count) and the cheapest listing is not the cheapest item. A targeted scan's lowest buyouts now also land in the store, so the next GetPrice answers from them.

Bug Fixes

  • A timer left armed by a cancelled targeted scan could advance the NEXT scan. C_Timer.After cannot be cancelled, so a scan cancelled between items (the AH closing does exactly that) left its "next item" timer armed; the player reopens the house and starts a new scan inside that delay, the stale timer fires _scanNext, and it pops the new scan's next item while the first query is still in flight -- or, with a one-item scan, finishes it as "0 of 0 items" before the result arrives. TOGPM's scanner had the same hole through AceTimer's ScheduleTimer, never cancelled. Found by this library's own suite: one example's leftover timer drained the next example's queue. Every targeted-scan timer now carries a generation number; StartTargetedScan and _finishScan bump it, and a timer from an older generation is inert. Pinned by "a timer left armed by a cancelled scan cannot advance the next one".
  • /itemdb price <link> split the argument on spaces, so a pasted link whose item name has a space in it ("Copper Ore") was refused as usage. The id is taken out of the link and the statistic from after its closing |r. Found by the spec before it shipped.
  • The usage line printed as [minBuyout|marketistorical|best] -- the chat frame reads |h as a hyperlink escape and swallowed it. Seen in game. Separators are now /, and the spec asserts no pipe past the coloured prefix in either help line.
  • /itemdb price refused an item NAME -- from the library whose reason to exist is resolving names. The last word is the statistic when it names one; the rest is an id or a name, resolved through ResolveName (case-insensitive; a suffixed name prices its base item); an unknown name says so rather than printing usage.
  • The Scan tab's refusal message was overwritten by its own repaint ("Could not start a scan: ah-closed" written, then the status repaint blanked it). Repaint first, then the refusal. Found by the window spec.
  • The Sources list rendered EMPTY on the first in-game open -- no rows, no header -- while every offline assertion passed. Found by the operator, from a screenshot. The cause: the TabGroup was given the "List" layout (the Scan tab's stacked controls need it), and an AceGUI TabGroup re-sizes ITSELF to its children after every layout (LayoutFinished -> SetHeight(children + 23 + borderoffset)). The Sources tab has no AceGUI children -- the RowList is a raw frame on the content -- so the group shrank to 53 px, its content frame to an explicit height of 0, and RowList, which sizes its visible rows from parent:GetHeight(), showed nothing. VersionCheck never meets this because its tab layout is "Fill". Fixed with the widget's own opt-out, tabs.noAutoHeight = true. Two things changed with it: the list now sits DIRECTLY on the tab content exactly as VersionCheck's does (the intermediate pane is gone) and the default-statistic picker moved to the window's bottom bar, left of the info "i", where VersionCheck parks its buttons; and a C_Timer.After(0) refresh runs after the window shows, because an anchored frame's height reads 0 until the client's next layout pass. Why the suite missed it: the spec asserted the list's DATA (#win.list.data == 4) and never its geometry. It now asserts the content height, the visible row count, that rows 1-4 are shown and row 5 is not, and the picker's rect against the info button's -- and the content-height assertion fails against the old code. Same lesson as the harness's own rule: an assertion that a widget was given data proves nothing about where it is drawn.

New Features -- Price/Window.lua: the configuration window

/itemdb (and /libitemdb): a LibAceGUIWidgets ClearFrame -- the suite's chrome, status bar naming the version and the realm scope, the info "i" -- with a TabGroup branded by W:BrandTabGroup, modelled on VersionCheck-1.0's roster window. Sources: a RowList with one row per source (Source / Detected / Enabled / Statistics / Order), a check icon that toggles the source, up/down icons that move it (hidden at the ends), a right-click menu carrying each source's extra toggles (Auctionator's historical fallback, Auctioneer's cached fallback, TSM's "Region data from the TSM Desktop App first"), and the default-statistic picker on the window's bottom bar. Scan: house open/closed, last full scan with age and count, live progress, Scan now (refusals reported in place), auto-scan on open, and the delay -- a "use this client's default" box over a 0.5-10s slider. Geometry persists in LibItemDB_PriceDB.window through W:PersistWindow. The window owns no data: rows come from GetPriceSources, every control writes through the settings API, and it repaints on the library's own callbacks, so a change made through the API shows without reopening. Built on first open and never before. /itemdb scan starts a full scan; /itemdb price <id, link or name> [statistic] prints what the ladder answers, with provenance.

Changed

  • All five TOCs, in lockstep: ## Dependencies: Ace3, VersionCheck-1.0, LibAceGUIWidgets (the window needs it, the same reason VersionCheck-1.0 declares it), ## OptionalDeps gains TradeSkillMaster_AppHelper and Auc-Advanced so the client loads them first when present, ## SavedVariables: LibItemDB_PriceDB, and the three Price\*.lua files after Integrations.lua. .pkgmeta required-dependencies gains libaceguiwidgets. Tests/toc_spec.lua now asserts the dependency line, the SavedVariables name (read from the source, so they cannot drift) and that Dependencies / OptionalDeps are identical across flavours.
  • .luacheckrc / .luarc.json: the auction-house, merchant and slash globals the price files read; ITEM_QUALITY_COLORS and the SLASH_* names as written globals; redundant-parameter disabled in the language server, whose arity for QueryAuctionItems and friends was being inferred from the specs' own stand-ins.

Testing

  • Three new spec files, 171 examples, 100% line coverage of all three price files (473, 438 and 371 executable lines), run through the harness gate. Tests/env_price.lua loads the library and the three files once per run and resets the SavedVariables table and the scanner's session state per example. price_spec.lua pins the ladder, every adapter's feature-detection and toggle-gating, the bad-return guards, nil-never-zero, provenance, the bulk form, the store's realm + faction scoping, the SavedVariables promotion, the merchant capture and the settings API. pricescan_spec.lua carries TOGPM's ahfullscan_spec / ahscanner_spec cases across (per-unit arithmetic, the zero-indexed modern API, every refusal of both scans) and adds the targeted scan driven end to end on both APIs, the stale-timer defect, and the load-time modern-API detection (the file is reloaded under a stub C_AuctionHouse, which it is written to survive). pricewindow_spec.lua builds the real window on env.frames with the installed Ace3 and LibAceGUIWidgets -- rows, actions, the right-click menu, the picker, both tabs, the callbacks repainting it, geometry persistence -- plus the frame-free halves (BuildPriceSourceRows, BuildScanStatus, FormatPriceAge, the slash parser). Suite: 520 passed, 0 failed.
  • Verified in game by the operator, Classic Era, Old Blanchy-Horde, read back from LibItemDB_PriceDB rather than assumed: the window's toggles, reorder and resize persisted through /reload; a full getAll scan priced 524 items (lastScanCount, every row a positive per-unit price and count); and /itemdb price on Roasted Quail answered 20s (TradeSkillMaster, Market value, age unknown) from the region-first adapter -- the same command printed "No price" before it. TOGProfessionMaster consumed this MINOR against those results and deleted its copies (its v1.0.11).
  • Harness pin 769c043 -> 82eb99e (26 days of adoption entries; nothing in them broke this suite, and GetServerTime on the driven clock, wow.flushTimers, and libs.load("LibAceGUIWidgets-1.0") are used by the new specs).
  • verify-manifest: 5 TOCs, 214 .lua, every one named by a manifest.

Documentation

  • README.md: a new Item prices section -- the contract, the four sources and what each carries (TSM's region-first order spelled out), the full API table, the callbacks, the window, and what is deliberately not here; the intro now names pricing as part of what the library is for. Requirements updated for LibAceGUIWidgets, the two new optional deps and the SavedVariables.
  • docs/Curseforge_Description.html: the price feature (configure once, every consumer uses it; region data first) in the feature list, the price API in the example, the dependency list, and the v0.8.0 notes grouped New / Changed / Fixed. The "Asking for something" section now points at GitHub issues and Discord instead of the frozen docs/LIBRARY_CONTRACTS.md / docs/AUDIT.md boards (frozen 2026-09-10; they stay as the historical record).
  • docs/DEPENDENCY_CONTRACTS.md §5: a dated note under the "not reading the App Helper blob" decision -- the region data IS consumed, through TSM_API, and the decision still stands.

Known Limitations

  • An automatic scan that the server throttles stays silent by design (TOGPM's behaviour, carried over): only a manual Scan now / ItemDB Scan prints "Full scan is on cooldown". A player with auto-scan on who opens the house inside the shared 15-minute window sees nothing happen. A status line on the Scan tab would close that; not done here.
  • TSM reports no age for any figure through TSM_API, so age is nil for every TSM answer -- including a realm snapshot the app stopped refreshing long ago. Region-first makes that the fallback rather than the answer, but a consumer showing "age unknown" for TSM is showing the truth.
  • The harness's env/libs.lua manifest counts tocOmits = 95 for LibItemDB-1.0 against ItemDB.toc; with three more shipped files that is 98 now, and verify-libs on the harness side will report the drift until the harness bumps it (sent to its inbox with this release).

[v0.7.1] (2026-08-22) - GetSlotRanking stops re-walking the whole database; two silent data defects caught by measuring instead of trusting

Two bodies of work. The first is the release's reason: a script ran too long field report, traced by benchmark rather than by reading the traceback, ending in a 5x faster GetSlotRanking. The second came out of proving that fix inert -- re-running a builder to show it changed nothing instead uncovered two shipped data defects that every check in the repo had passed: required levels served for seasonal items on non-seasonal realms, and a locale builder that overwrote 41,777 real names with English and exited 0. Both are fixed here, and both are recorded in docs/AUDIT.md (findings 33 and 34) because the way they hid is worth more than the fix.

A field report from a v0.7.0 player: two script ran too long errors, both with LibItemDB in the innermost frame. Neither named line was the cause, and the first job here was refusing to "fix" them. script ran too long is the client's per-execution watchdog -- it fires wherever the interpreter happens to be standing when the budget for the whole click runs out. The two frames it named were unpackCore's strsplit return (:259) and ScoreStats' if not w then return nil end (:2308), reached from a BuffEP that makes exactly one scoring call. Ten seconds were already gone before either was entered.

So the work was to find what actually spends a frame budget, by measuring it. Benchmarked against the shipped Vanilla data (17,604 items) on desktop Lua 5.1 -- the client's interpreter is slower again, so these are a floor, not an estimate of what the player saw:

Call Before After
GetStats(id) 5.5 us 3.8 us
GetItemScore(id, ...) 8.8 us 7.8 us
GetSlotRanking (no pool) 44.1 ms 8.4 ms
a 17-slot Planner draw 0.75 s 0.14 s

Bug Fixes

  • GetSlotRanking with no pool walked and re-scored the ENTIRE database, once per slot. It built an array of every id in self.core and handed all 17,604 of them to RankItems, which unpackCore'd each one only to discard the ~95% not in the requested slot -- and then did the whole thing again for the next slot. A consumer drawing one item picker per equipment slot (the documented use, and what Dibs' Planner does) therefore paid seventeen full-database walks for one screen: 0.75 s on desktop Lua, and enough on its own to trip the client's watchdog mid-draw. slotIndex now buckets core by equipLoc once, lazily, and each ranking scans only the items that can possibly match. LoadCore invalidates it, which is the part that could go wrong silently -- a _seasonal overlay or a second addon's LoadCore landing after a consumer had already ranked something would otherwise be invisible to every later ranking -- so that is specced directly, with an assertion first proving the index really was warm. A nil slot ("rank everything") still walks core, since no per-slot bucket can serve it. Public API, signature and results are unchanged, so there is no MINOR bump and nothing to feature-gate. Location: LibItemDB-1.0.lua (slotIndex, lib:GetSlotRanking, lib:LoadCore).

  • Ranked items that score EQUAL came back in a different order on every call. The pool was built by walking pairs(self.core), whose order is arbitrary, so table.sort broke ties differently each time and a UI redrawing the same ranking could reshuffle rows that had not changed. Building the index once fixes it as a side effect: within a session the order is now stable. Found while writing the fix above, not reported.

Performance

  • GetStats allocated three tables per call to merge two extras. It walked ipairs { self.equipStats[id] or "", self.effects[id] or "" } -- an array literal per call -- and ran each blob through decodeStats into a throwaway table just to pairs() it across. It is the hottest getter in the library (a consumer scoring a loot browser calls it tens of thousands of times per refresh), so the new mergeStats layers a blob straight into the table that is being built: 5.5 us -> 3.8 us, and no array. The or "" on each extra was load- bearing in the old shape rather than defensive -- a nil FIRST element ends ipairs immediately, which had once silently dropped the effects blob for every consumable -- so the trap goes with the array, and the reason is recorded at the call site rather than deleted with the code. Search carried a second copy of the same loop, sentinel and all, and now shares the helper; that duplication is why the consumable bug had to be fixed twice. Location: LibItemDB-1.0.lua (mergeStats, lib:GetStats, lib:Search).

Data

  • build-locales.py let a partial wago export overwrite 41,777 shipped Traditional Chinese names with English, and exited 0 (docs/AUDIT.md finding 34). The try/except around the name fetch only catches a throw; an export that returns successfully but half-empty is not an exception, so the missing ids take the documented English-fallback path and are written over real localized strings. Measured on the Mists 5.5.4.68806 rebuild: healthy locales fell back on ~22 of 88,260 names (0.02%), while zhTW came back 37,465 localized against 50,794 fallbacks (57.6%). Its diff was 41,777 insertions / 41,777 deletions and its log line looked like every other locale's. Reverted; guard added -- a locale exceeding --max-fallback-pct (default 5%, two orders of magnitude above healthy and one below the observed failure) is refused and its existing file left alone. The guard immediately caught three more on the next run: esMX 72.9%, frFR 69.3%, zhTW 57.6%. The shape is the harness's own 2026-08-19 rule in a new place -- the builder diagnosed "this name is not localized" from an absence, and the absence was a broken fetch, not a fact about the data.

  • The same run silently skipped five locales outright while still exiting 0, so one green exit code covered a data loss and five no-ops. That is why the Mists result below is reported as a fraction rather than as done.

  • Locale build stamps: TBC complete, Mists 6 of 11 (finding 16). TBC's 23 locale files rebuilt with every diff header-only and 30,032 names byte-identical across 11 locales. Mists rebuilt all 12 RandomProps.lua and six Names.lua (enGB, itIT, koKR, ptBR, ruRU, zhCN -- the last also picking up three genuine corrections); deDE, esES, esMX, frFR and zhTW were refused and left untouched, keeping their real names and their old unstamped headers. A missing stamp is cosmetic; an overwritten name is not. The fetch is flaky rather than broken -- three of those locales threw on one run and returned partial data on the next -- so the retry needs no code change, only a healthy export.

  • _core needed no rebuild on either flavour, which is what measuring first established: all 14 TBC wago files already carry the pin 2.5.6.68941 and both Mists wago files 5.5.4.68806. The 16 TBC / 17 Mists files stamped 20505 / 50503 are walk-sourced interface stamps and correctly outside the mosaic -- they are not to be "fixed". enUS/Names.lua carries no stamp on any version and that is also correct: it is the walk locale, written by build-core.py and deliberately skipped by build-locales.py.

  • Data/Vanilla/_core/ReqLevels.lua shipped UNSPLIT while _seasonal/ReqLevels.lua also existed, so 5,108 seasonal ids were defined twice (docs/AUDIT.md finding 33). Every other split dataset partitions base and overlay between them; this one carried the full 15,652 rows in the base and its 5,108 seasonal rows again in the overlay, so GetRequiredLevel answered for seasonal items on a non-seasonal realm -- the one thing the lib:IsSeasonalRealm() gate exists to prevent. The split now holds: 10,544 + 5,108 = 15,652, each file stating its own real count.

  • How it was found, because the method matters more than the fix. Re-running build-vendor-prices.py Vanilla to prove an unrelated change was inert regenerated SellPrices.lua unsplit (13,771 -> 18,140 rows) -- which is the standing hazard that any builder rerun reverts the partition and split-seasonal.py must follow it. Re-running the split restored SellPrices byte-identically and moved ReqLevels' 5,108 rows to the overlay they should always have been in. Nothing had caught it: the suite was green, verify-manifest passed, and the header matched the row count -- because v0.7.0's finding 32 had corrected that header to 15,652, which made the unsplit file look consistent rather than wrong. A header that agrees with its own file says nothing about whether the file is the right half.

Documentation

  • New docs/LIBRARY_CONTRACTS.md -- the board for the addons that CONSUME this library. DEPENDENCY_CONTRACTS.md points outward and up (what ItemDB needs from what it depends on) and had no room for traffic pointing the other way, so a finding about a consumer landed in it and was moved out the same day; §8 there is now a stub saying where it went. Dibs has its own docs/LIBRARY_CONTRACTS.md for asking libraries for things, and the obvious move was to reply in it -- but other repos are read-only from here, which is the rule the whole contract system rests on. The user settled it: "you can use yours and i'll tell dibs to read it." Format, markers ([VERIFIED] / [READ] / [SURVEYED] / [IDB-CLAIM] / [NEED]) and the reply-in-a-blockquote convention are taken from Dibs' file, which took them from TOGBankClassic's, so every repo in the suite sees one document shape. It is also the file the session watcher has been aimed at since before it existed.
  • IDBREQ-DIBS-001 records the half of the report ItemDB cannot fix. Dibs rebuilds its attackOpts equipped-weapon context (two GetInfo + two GetStats) and Loadout:EquippedEP (a full GetItemScore on your worn gear) per item row, when both are constant for a whole refresh. The entry states what was measured, what ItemDB already fixed, and the three changes Dibs still needs -- written up rather than applied, per this file's read-only rule about other repos.
  • Dibs answered within the hour, and corrected two things ItemDB wrote. Both accepted, mirrored into LIBRARY_CONTRACTS.md 1.1 rather than quietly amended. Items 1 and 2 are done on their side (unreleased v0.4.3), item 3 declined for a stated reason -- capping what a browser shows is the user's call, not something to decide inside a performance fix. They did not promote the benchmark table, which is correct: their spec counts CALLS, not microseconds.
    • PLAYER_ENTERING_WORLD was missing from the invalidator ItemDB specified. GetInventoryItemID answers nil for slot empty AND for inventory not loaded yet, and a consumer cannot tell them apart -- so a first score taken before the inventory lands caches 0 for every slot, and no equipment event fires merely from logging in, leaving it wrong all session. A consumer following the original wording literally inherits that bug.
    • The claim "the ONE honest level-linked effect" was an over-claim, and the real one was bigger. Items:PlayerClassSpec cached its talent scan only when points were spent -- so every character below level 10 re-walked all three talent trees through the deprecated Specialization shim, once per item, via Weights:Source. That is the level-2 case in its purest form and has nothing to do with a thrown weapon. It was named from two of the four tracebacks that report carried, on a repo that was read rather than run. The finding was right; the completeness claim stacked on top of it was not, and that kind reads as authoritative and stops the other side looking. Nothing about ItemDB's own fix changes.
  • The consumer board's reply rule was wrong on the day it was written, and Dibs could not follow it. It said "consumers read and respond here" -- but an outside session may write only the shared conversation files in another repo, so that invited a write their repo law refuses. Two rules disagreeing, and ItemDB's was the wrong one. Replaced with a loop that works: ItemDB raises here, the consumer replies in their own board, and an ItemDB session mirrors it back -- an obligation, not a courtesy, since until it happens the ticket reads as unanswered to anyone reading only this file. Dibs had to flag the first one for a human.
  • IDBREQ-DIBS-002: an ItemDB [VERIFIED] claim on Dibs' board had gone stale, and correcting it caught ItemDB repeating an unverified inference one paragraph after warning about them. Their section 1.4 still closes with ItemDB's 2026-08-03 "Settled: ItemDB ships ItemDB_BCC.toc only. ItemDB_TBC.toc is deleted." ItemDB reversed that since; the repo ships ItemDB_TBC.toc and no _BCC, pinned by Tests/toc_spec.lua. A [VERIFIED] claim by a library about its own shipping filenames, under a heading marked settled, is the least likely thing anyone re-checks -- and the consumer cannot catch it, because the claim is the library's.
    • The first draft of that correction then repeated the error it was correcting. It accepted Dibs' reasoning that "eleven working addons would not be silently falling back to their Vanilla tocs, so both suffixes load" and published it as fact. Wrong: per the harness's own docs/TOC.md, the separator is part of the name -- modern suffixes take an underscore, the two legacy ones (-BCC, -WOTLKC) a hyphen, and -BCC was dropped in Patch 2.5.5 -- so the underscored _BCC.toc was never a recognised special name on any client and falls through to the unsuffixed TOC. The reductio is the actual situation: that harness doc's 2026-08-07 fleet sweep found six addons in exactly that state, and lists ItemDB as clean. The wrong text is left standing in the board with the correction appended under it, which is what the append-only law is for -- and which is a better record than a silent swap would have been.
    • Two enforced laws collided while fixing it. Append-only refuses any edit that does not preserve existing text, including a one-character * -> _ swap; MD049 demands one emphasis style per file, set by whoever wrote first. In an append-only file no later appender could ever normalise it. MD049 is now disabled in LIBRARY_CONTRACTS.md with that reasoning written in the file; MD050 stays on, since it still catches something.
  • Tests/HARNESS_CONTRACT.md: the harness's env/wow.lua flavour-alias comment names ItemDB as shipping _BCC.toc. It does not, and the comment also presents that spelling as a live alternative, which the harness's own docs/TOC.md contradicts. Raised as a comment correction only -- the bcc = 5 alias should stay, several addons still carry the filename, and nothing in ItemDB is blocked.
  • Archived v0.5.0 and v0.4.7 into CHANGELOG_ARCHIVE.md. This entry took the live file to 116,065 characters -- 3,935 short of the 120,000 working ceiling, under GitHub's hard 125,000 limit on a release body -- and the packager publishes CHANGELOG.md verbatim as that body, so the next entry would have been the one that failed the release. Moved at version boundaries, nothing edited, newest-first order preserved; the live file is back to 77,438 characters with 42,562 to spare. v0.7.1, v0.7.0 and v0.6.0 remain live.
  • The session watcher now covers Dibs/docs/LIBRARY_CONTRACTS.md. ItemDB learned of that reply because the user relayed it. The standing rule is that an addon watches the conversations it is a party to and not the fleet's; a board section that answers an ItemDB ticket is exactly such a conversation, and leaving it out made the reply arrive by hand. Stored in the watcher spec so the next session arms it without re-deriving the path.
  • The level-2 character in the report is evidence, not a mechanism, and the entry says so. The traceback's locals carry a quality-1 item-level-3 INVTYPE_THROWN weapon -- starter kit in the ranged slot, which is how we know the reporter was on a fresh character. ItemDB has no level-dependent code path: nothing in the library branches on player level and no scoring path reads GetRequiredLevel. That half stands; what was published alongside it did not. A link was named on the Dibs side -- something in the ranged slot makes weaponContext(equippedRangedID()) do real work per row instead of returning nil at once -- and it is real, but calling it the link was wrong: the bigger one was their talent-scan cache, which Dibs found the same day (see the correction bullet above). The lasting point is the one that survived being wrong: saying "ItemDB has no level-dependent path" was verified and cost nothing to be sure of; saying which single mechanism did it, about a repo that was read rather than run and from half the tracebacks, was neither.

[v0.7.0] (2026-08-20) - Both directions of a vendor transaction; correct proc trigger rates; school-gated spell-damage EP; a reviewed Vanilla rebuild

Two bodies of work ship together here. The vendor-price half was written 2026-08-07 and its sections are unchanged below; the proc / scoring / rebuild half is 2026-08-20. Nothing in this entry has been released before.

New Features -- proc trigger scopes and school gating

  • GetItemScore now honours opts.schools (LibItemDB-1.0 MINOR 24). A consumer could already pass the magic schools a spec actually casts, and this library silently ignored it -- so a holy priest banked full EP for shadow-specific spell damage, and a frost mage for a +Fire off-hand. The data had shipped for months (SpellSchools.lua, lib:GetItemSchools); only the scorer never consulted it. Now an item whose spell damage is school-restricted earns that EP only when the caller's set and the item's intersect -- one match is enough, so a fire/frost spec keeps full value on a +Fire piece. Only the school-conditional key is dropped: the item's other stats still score, and its set bonus, on-use and proc are untouched because none of them carries a school of its own. An item with no school tag is generic +spell damage and always counts. Omit the option and every score is bit-identical to MINOR 23, which is what makes this safe to ship into live consumers. The gated key is declared by the expansion (schoolGated in Scoring/Vanilla.lua and Scoring/TBC.lua), not hardcoded in the core, and it is matched POST-alias so ITEM_MOD_SPELL_POWER and ITEM_MOD_SPELL_DAMAGE_DONE are both covered by one entry. GetItemScoreBreakdown gates identically through the same shared helper, and a gated stat is omitted from parts rather than emitted as a zero row. Seven specs, five of them negative controls; mutation- checked by reverting the gate, which reddens exactly the two gating assertions and leaves the five "unchanged" controls green. Routed from the Dibs board (docs/AUDIT.md finding 20). Location: LibItemDB-1.0.lua, Scoring/Vanilla.lua, Scoring/TBC.lua.

Bug Fixes

  • Factions.lua silently lost 155 items whenever it was rebuilt on a current client. wago renamed the Faction table's race-mask columns at build 1.15.9.68808: what was ReputationRaceMask_0..3 is now ReputationRaceMasks0_0, ReputationRaceMasks0_1 and so on. race_mask() knew two spellings and neither matched, so it returned nil for every faction, the factionID -> side map came back empty, and every item whose side comes only from MinFactionID dropped out -- the 155 Arathi Basin and Alterac Valley reputation rewards (factions 509 League of Arathor, 510 The Defilers, 729 Frostwolf, 730 Stormpike). No error, no warning, and a header that read plausibly: 531 items where the shipped file has 686. This is the identical failure the function's own docstring already recorded for TBC, arriving a third time. Fixed by teaching race_mask() the array spelling, and by making the silence impossible: the builder now exits with a message naming the columns it did find if the Faction table yields no sides at all, or if ItemSparse yields no race mask for any core item. There is no build in which nobody picks a side, so an empty map is always a schema change and never a fact about the data. Rebuilt output is 686 items (348 A / 338 H) -- exactly the shipped counts, now at build 1.15.9.69109. Location: tools/build-factions.py.

  • The same defect was still live in the shipped TBC data, and the rebuild recovered 151 items. Fixing race_mask() fixed the builder; TBC's Factions.lua had never been regenerated since, so it kept shipping the broken reader's output while its header read 2.5.6.68941 and looked current. That is the trap this class of bug keeps setting: a stale artefact is indistinguishable from a fresh one, because the stamp records the DB2 it was built against and not the code that built it. TBC now reports 284 restricted items (137 A / 147 H), up from 133 (68 A / 65 H). What came back identifies itself: they are the racial mounts -- Horn of the Black Wolf and Horn of the Red Wolf as Horde, Black Stallion Bridle, Pinto Bridle and Chestnut Mare Bridle as Alliance -- which are precisely the items whose side comes from a race mask and nothing else. Until now FactionUsable returned true for every one of them on TBC, so a ranking would happily recommend an Orc a horse. Location: Data/TBC/_core/Factions.lua.

  • Data/Vanilla/_core/ReqLevels.lua shipped 10,544 rows while its header claimed 15,652. So 5,108 Vanilla items carried no required level at all while the file asserted they did: GetRequiredLevel answered 0 -- which is the real domain value meaning "no requirement", not a miss -- for every one of them, and GetInfo's 7th return was wrong for roughly a fifth of the database. A consumer sorting a bag by required level, or gating "can I equip this yet", got a confident wrong answer with nothing to indicate it. Found by arithmetic rather than by eye: a --stat diff reported +5,110 lines on a file whose header count had not moved, which is a contradiction; --numstat gave 5,109 insertions against 1 deletion, and a pure-addition diff means every previous row survived, so the old file held 15,652 - 5,108 = 10,544. The rebuilt file measures consistent -- 15,652 rows against a header of 15,652. This is finding 22's shape (a header true of neither side) and it had shipped, which is why the remedy is the rule rather than the row count: a stat-diff that disagrees with a file's own self-reported count is a defect signal, not noise. Location: Data/Vanilla/_core/ReqLevels.lua.

  • split-seasonal.py could leave BOTH halves of a file stating a row count true of neither. Its header correction tried two candidates -- the pre-split total and the other half's count -- and refused (correctly, by design) when neither matched. But pre_total is the count of the union of the base file and the existing overlay, while a freshly built file states only its own rows; those diverge the moment a rebuild at a newer client build drops ids the overlay still carries. Measured: the 11 non-enUS Vanilla Names.lua were emitted with 23,448 names, the overlay held 679 ids that build no longer has, so pre_total was 24,127, neither candidate matched, and all 22 files shipped a header claiming 23,448 against real counts of 17,604 and 6,523. enUS escaped only because build-locales.py does not write it. Fixed by adding the base file's own arrival count as the first candidate. The exactly-once rule is unchanged, so this cannot make an ambiguous header guessable -- it only lets an unambiguous one that no candidate happened to name be corrected. All 24 Vanilla Names.lua now match their headers. Location: tools/split-seasonal.py.

Improvements

  • The Vanilla dataset is now one build, and every generated file carries its stamp. All 78 Vanilla data files were regenerated pinned to 1.15.9.69109, collapsing the three-stamp mosaic (docs/AUDIT.md findings 16-19) to a single build. Hidden.lua carries a build stamp for the first time on any version -- it had none, so its drift was undetectable by reading the repo -- along with the ATT removed-with-patch cutoff it also never recorded, and RepLoot.lua, ItemLocations.lua and all 24 Names.lua gained theirs. Verified rather than asserted: every one of the 78 files diffs as header lines only (1-2 lines each), and the row counts are unchanged across the whole tree, so no shipped score moved. The two exceptions in the diff (Sets.lua, UseEffects.lua) are the earlier pm= change set, not this rebuild. Location: Data/Vanilla/.

  • ItemDB.toc now lists Data\Vanilla\_seasonal\ItemLocations.lua. The rebuild's seasonal split created that file for the first time and the TOC lists the _seasonal files by hand, so it shipped named by no manifest and would have loaded on nobody -- item locations silently missing for every seasonal item. Caught by verify-manifest.lua, which is exactly the hazard that check exists for and which a green suite cannot see. Location: ItemDB.toc.

  • header()'s docstring no longer claims a migration that is finished. It said "NOT YET MIGRATED: the 13 builders that already hand-write the stamp still do", which stopped being true when the finding-19 remainder landed -- no builder hand-writes the line any more. Location: tools/itemdb_common.py.

  • The proc trigger-scope classifier is a real bit test now, in ONE place (finding 15, tier 3). sc came from mask >= 0x10000 -- a magnitude compare on a bitfield -- written out twice, as a function in build-use-effects.py and re-implemented inline in build-sets.py. Three sessions recorded the bit meanings as "not established anywhere on this box" and treated that as a blocker. It was not one: they are public emulator source, and CMaNGOS mangos-classic (the Vanilla tree this addon targets) and TrinityCore 3.3.5 agree bit for bit, which is what makes a Wrath-era table safe to apply to Vanilla data. The old rule was wrong in both directions: 0x4000 (DEAL_HELPFUL_SPELL, a heal cast) is below the threshold and read "any", while 0x40000 (DEAL_HARMFUL_PERIODIC, a DoT tick) and 0x100000 (TAKE_ANY_DAMAGE) read "spell". itemdb_common.proc_scope now tests bits and answers spell / any / periodic / taken; a mask carrying both cast and swing bits resolves to any deliberately, because the effect does fire on melee and the swing rate is the one a paperdoll can actually measure. Verified against the three masks measured from wago_cache plus both old-failure directions: 7/7 correct, 4 of the 7 changed by the fix. The data IS regenerated on both flavours, and every shipped proc row's sc was checked against the classifier afterwards: 504 rows, 0 disagreements. Final distribution: any 438, spell 34, taken 29, periodic 3. Location: tools/itemdb_common.py, tools/build-use-effects.py, tools/build-sets.py, Data/*/_core/Sets.lua, Data/*/_core/UseEffects.lua.

  • All four proc trigger rates now ship, derived rather than curated. castsPerSec had been declared by no module since MINOR 23, and the two new scopes would have joined it -- three named holes all falling back to the swing rate. They are filled, and each number says where it came from:

    • hitsTakenPerSec = 0.5, MEASURED from CMaNGOS creature_template.MeleeBaseAttackTime over the raid-boss population: the mode is 2000 ms in both eras (102 of 147 Vanilla bosses, 150 of 175 TBC), and the means agree at 2069 ms and 1983 ms. Assumption stated in the builder: exactly one boss on you.
    • ticksPerSec = maintained DoTs / 3 s, MEASURED from SpellEffect.EffectAuraPeriod: 3000 ms is the plurality in both eras (38.9% of 1,282 periodic auras at 1.15.9.69109; 44.0% of 1,600 at 2.5.6.68941) and is the exact period of every canonical DoT read individually -- Corruption, Shadow Word: Pain, Immolate, Renew, Rejuvenation. The assumption is how many DoTs a spec keeps up, which no table records, so it is written per class with the reasoning.
    • castsPerSec = 0.4 for casters, adopted from a number this repo already shipped rather than re-derived: HITS_PER_SEC assigns 0.4 to Priest/Mage/Warlock precisely because "casters trigger off ... casts". The GCD caps any caster at 0.667/s. The melee values are the judgement half and are labelled as such. Location: tools/build-score-model.py, Data/Vanilla/_core/ScoreModel.lua, Data/TBC/_core/ScoreModel.lua.
  • Verified on real items through the real shipped data, not only on fixtures. Scoring a warrior/protection loadout: Skullflame Shield 47.94 EP with no paperdoll and 47.94 with a 5-swings-per-second one -- identical, which is the whole point. Freezing Band 0.78, Girdle of Reprisal 7.99, Grand Marshal's Aegis 87.62, all likewise unmoved by weapon speed. Before this change every one of them scaled with how fast the wearer attacked.

  • ~26 "chance when struck" items were priced off how fast the WEARER attacks. Found by listing the rows the new classifier changes instead of trusting that three sample masks covered the space -- which they did not. Reading only TAKE_ANY_DAMAGE (0x100000) misses every effect that names the SPECIFIC incoming types instead of the generic one, and almost all of them do: pm=0x28 and pm=0x2A8 are TAKE_MELEE_SWING|TAKE_MELEE_ABILITY(|TAKE_RANGED_*) with 0x100000 clear. That is Skullflame Shield, Freezing Band, Girdle of Reprisal, Vile Protector, Truesilver Breastplate, Thermaplugg's Central Core, Grand Marshal's Aegis, High Warlord's Shield Wall, Battlegear of Wrath and Darkmoon Card: Vengeance -- every one a canonical when-struck item, every one classified sc="any" and therefore rated by the wearer's own swing rate. PROC_ON_TAKEN now covers the whole TAKE_* half of the enum. Location: tools/itemdb_common.py.

  • The proc rate rule is now one sentence, and it covers all four scopes. hitsPerSec is the only rate opts.attack can override, so it is the only rate a swing-triggered proc may read; every other scope resolves off the MODEL, because nothing in a character sheet knows how often you cast, how often your DoTs tick, or how often you get hit. scoreProcEffects gains ticksPerSec (DoT/HoT ticks; falls back to castsPerSec, since a tick implies you cast the DoT) and hitsTakenPerSec (incoming attacks, a tanking number; falls back to the model's hitsPerSec). Both OPTIONAL and both defaulting to a rate that is already correct, so no shipped module declaring neither can see a score move -- the same staging finding 9 used, and the reason these are two separately-named holes rather than one constant doing four jobs. Without this the rebuild would have REVERSED finding 9's fix: periodic and taken would have fallen to the else branch and started reading the paperdoll, so Timbal's Focusing Crystal would have gone from a cast rate to a swing rate. Mutation-checked by restoring the two-way branch: the paperdoll assertions fail at 1750 against an expected 175, a 10x inflation, while the "no rate declared" control stays green. Location: LibItemDB-1.0.lua.

New Features -- vendor prices (written 2026-08-07)

  • lib:GetVendorSellPrice(itemID) — what a vendor PAYS YOU, in copper. LibItemDB-1.0 MINOR 22. With GetVendorBasePrice below, the library now answers both directions of a vendor transaction offline. Location: LibItemDB-1.0.lua (lib.sellPrice, lib:LoadSellPrices, lib:GetVendorSellPrice), tools/build-vendor-prices.py, Data/{Vanilla,TBC}/_core/SellPrices.lua. 18,140 Vanilla / 21,720 TBC items.

    Why ship it when GetItemInfo already returns it — the reason is the cold cache. GetItemInfo's 11th return is the same number, but only for an item the client has cached; on a cold cache it returns nil for every field and forces a GET_ITEM_INFO_RECEIVED retry loop. That is the exact rationale GetRequiredLevel already ships on, and it is the founding reason this library exists. A consumer reading the live value will look correct in every test run on items just inspected, and render nothing on a recipe the player has never seen.

    No vendor gate, and the asymmetry is deliberate. Any vendor buys anything, so "what will I be paid" is answerable for every item with a sell value; "what does it cost" is only meaningful where a vendor actually stocks it, which is what GetVendorBasePrice's npc_vendor intersection is for. Consequence: nil from GetVendorSellPrice is a clean statement (the item has no sell value), unlike nil from GetVendorBasePrice, which conflates four conditions.

    No reputation discount is applied, and the spec pins that as an assumption rather than a fact. Faction discounts are believed buy-side only — the sources define a faction discount in terms of buying and say nothing about selling — but this is not verified, and no client source can settle it: searching all four flavour trees in the Blizzard UI source, the only discount references are the in-game store, transmog and item upgrade. The mechanic is entirely server-side. Tests/libitemdb_spec.lua carries a test whose stated job is to fail if that ever changes.

New Features (buy price)

  • lib:GetVendorBasePrice(itemID) — what a vendor CHARGES you, in copper, or nil. LibItemDB-1.0 MINOR 21. Requested by TOGProfessionMaster (docs/DEPENDENCY_CONTRACTS.md §7) for the last-resort tier of its cost-to-craft chain, which has to price reagents no live source can — thread, vials, dye, flux.

    This is not sellPrice, and the distinction is the whole feature. GetItemInfo returns what a vendor pays you; the client has that natively. It has no buy price at all — checked rather than assumed, there is no buyPrice field anywhere in ItemDocumentation.lua, and the only client route is GetMerchantItemInfo(index), which needs an open merchant window and answers for the item in that slot. "What would this cost" is unanswerable online, which is why it ships as data.

    Location: LibItemDB-1.0.lua (lib.vendorPrice, lib:LoadVendorPrices, lib:GetVendorBasePrice), tools/build-vendor-prices.py, Data/{Vanilla,TBC}/_core/VendorPrices.lua.

  • The gate is two sources intersected, and the second is not optional. wago ItemSparse.BuyPrice supplies the price; emulator npc_vendor supplies whether a vendor sells it at all. BuyPrice is roughly sellPrice * 4 and is populated on nearly every item in the game, drop materials included — it is a price, never a vendor-availability signal, so shipping it alone would tag Thorium Ore with a price no vendor will honour. Vendor inventories are server-side and appear in no client DB2. The gate is maxcount == 0 AND ExtendedCost == 0 (unlimited stock, bought with gold), which is the same reasoning Auctionator uses when it caches only numAvailable == -1.

    Verified on the built data: Coarse Thread 10c, Fine Thread 1s, Silken Thread 5s, Rune Thread 50s, Strong Flux 20s, Red/Blue Dye 50c, Salt 50c — and Thorium Ore, Copper Ore and Coarse Stone correctly absent.

Improvements -- vendor prices

  • Cross-checked against TOGProfessionMaster's independent extraction: 59 of 59 overlapping values agree, zero disagreements. Its shipped Data/VendorPrices.lua holds 93 rows built by a different script in a different repo; every id it shares with ours carries the same copper value, and the one Vanilla-file absentee (23572) is a TBC item correctly excluded from the Vanilla dataset and present in the TBC one. Two independent extractions agreeing is the check that found real bugs in the §1 recipe-scroll migration, so it was run here before claiming the data was right.

  • Audit finding 1 fired on this very change, which is the strongest argument for fixing it. Generating SellPrices.lua made split-seasonal.py partition 4,369 Vanilla rows into Data/Vanilla/_seasonal/SellPrices.lua — a file no TOC listed, because the seasonal manifest block is hand-maintained. Caught only because the finding had just been filed and the builder's output was read. Unnoticed, 4,369 seasonal items would have had no sell price on SoD/Anniversary realms, silently, with a green suite. The TOC line is added; the missing guard (assert every data file on disk is named by some manifest) remains open as finding 1.

  • shipped_ids() moved into itemdb_common.py. build-req-levels.py had the only copy; build-vendor-prices.py needed the same gate, and a second copy of a rule about which ids a version ships is exactly the kind of pair that drifts silently. One definition, two callers. Location: tools/itemdb_common.py, tools/build-req-levels.py.

  • The emulator dumps are read from a sibling ProfessionDB install, not duplicated. New itemdb_common.npc_vendor_files(), the same reach-across-to-a-sibling-addon shape as the existing att_categories_dir(). It prefers a local tools/emulator_data/ if one exists. The relevant input is 2.7 MB across two *_npc_vendor.sql files, not the ~459 MB the whole emulator_data directory weighs — that figure is almost entirely one full TrinityCore world dump this gate never opens.

  • The builder fails loudly when the dumps are missing, rather than emitting an empty table. A shipped-but-empty VendorPrices.lua is indistinguishable at runtime from "no vendor sells anything": the library would answer nil for every item and nothing would report a problem.

Bug Fixes -- EP scoring, all found by peer review (docs/AUDIT.md rounds 6-13)

Nine findings, every fix mutation-checked. Scores change for affected items: these were all silent zeroes, and a zero was invisible rather than wrong-looking because scoreProcEffects, GetSetBonusEP and GetItemScoreBreakdown each drop a row whose EP is 0 -- so a consumer saw no row at all, indistinguishable from an item that has no such effect.

  • Every heal proc in both flavours scored exactly zero (finding 7). Scoring/Vanilla.lua and Scoring/TBC.lua shipped epPerHPS = 0, so the heal bucket (c = (e.m * rate) * model.epPerHPS * hw) was identically zero for every class and spec. It was an unfilled constant, not a policy. Now 3.5, derived rather than tuned: epPerHPS is healing power per 1 HPS, and Classic's direct-spell coefficient is castTime / 3.5, so +H healing power yields H / 3.5 HPS and 1 HPS costs 3.5 healing power. Worst case fixed: Bonescythe Armor's entire set value is one heal proc, so the Rogue tier set contributed nothing.

  • Every b="damage" proc scored exactly zero for every caster spec (finding 11). The damage bucket priced procs only through a weapon-DPS weight, and no caster scale carries one: priest, mage and warlock all price ITEM_MOD_SPELL_DAMAGE_DONE with neither DPS_MAINHAND nor DPS_RANGED, while shaman enhancement has DPS_MAINHAND=3, which is what shows the split is real rather than a gap in the data. A caster route now converts bonus DPS to a spell-damage-equivalent. It is a fallback, not a replacement -- a class with a weapon weight is untouched.

  • epPerRawDPS was shipped, documented, per-class overridable and read by nothing (finding 8). It was orphaned, not dead: the missing half of the conversion the caster route needed. Now 3.5 by the same coefficient rule (direct damage is also castTime / 3.5) and wired to that route. Deleting it would have been the tidy wrong answer.

  • sc (trigger scope) was carried in the data with two real values and read nowhere (finding 9, route half). It now selects the route: an sc="spell" proc takes the caster route even on a class that swings. This matters for hybrids carrying both weights -- paladin protection/retribution and shaman enhancement -- where a spell-triggered proc was priced by how often the class swings, overvaluing it by roughly 3x. Requiring a spell-damage weight before switching is load-bearing: a pure melee scale must keep the weapon route rather than falling through to zero.

  • A paperdoll could drive a proc that fires on a CAST (finding 9, rate half). LibItemDB-1.0 MINOR 23. hitsPerSec was one number doing three jobs -- Scoring/Vanilla.lua calls it a "default proc trigger rate", the library calls it "triggering hits/sec", and GetItemScore takes it from opts.attack, which is a paperdoll and can only report weapon swings. So the moment a consumer wired the paperdoll (the documented intent) every sc="spell" proc would start moving with the speed of whatever weapon the player held -- Vestments of Faith's 8-piece triggers on a priest's spells and would have tracked their mace. A spell-scoped row is now priced off model.castsPerSec where the flavour declares one, and off the model's hitsPerSec -- never the paperdoll's -- where it does not. Location: LibItemDB-1.0.lua (scoreProcEffects).

    No cast rate is invented and no shipped score moves. No rules module declares castsPerSec, so the fallback is exactly what the callers already resolved when no paperdoll is wired: every current number is bit-identical, which the specs assert directly. What changed is that opts.attack can no longer reach a spell-scoped row. Choosing the actual value is still open and is a design question rather than a defect -- there is no cast-rate field in the data and no paperdoll wired in, so picking a number here would be the invented-constant failure this repo names. Recorded in docs/AUDIT.md rather than guessed at.

  • A proc's trigger scope sc is decided by a MAGNITUDE test on a BITFIELD (finding 15). proc_scope reads as "does this mask have the spell bit"; mask >= 0x10000 does not test that bit or any bit, so every high bit collapses to one answer. Measured from the DB2 cache, three rows the data all labels sc="spell" share no common bit: Shiffar's Nexus-Horn 0x14000 (genuinely on-cast), Timbal's Focusing Crystal 0x240000 (a DoT-tick proc) and Battlegear of Might's 5-piece 0x100000 -- the warrior tier set, currently scored as a mana restore on spell cast at a warrior's swing rate.

    Not fixed, and deliberately not guessed at: no constant turns a magnitude compare into a bit test, and the bit semantics are established nowhere on this box -- absent from the Blizzard client tree, and CMaNGOS's spell_proc_event.procFlags is 0 for these spells, meaning the emulator defers to the very field we already read. What shipped instead is the raw mask, as pm beside sc, in Sets.lua and UseEffects.lua on both flavours (LibItemDB-1.0 MINOR 23). That cannot lose information -- the mask is the canonical datum even the emulator falls back to -- so a correct reading can be recomputed later with no data rebuild. sc is unchanged everywhere, so no score moves; every rebuild was pinned to the build already stamped in the file and each diff verified to contain the new field and nothing else. Location: tools/build-sets.py, tools/build-use-effects.py, LibItemDB-1.0.lua.

  • The pipeline has three input tiers and only one of them could be recorded (finding 16). A generated dataset can only be audited by re-running the thing that made it, and two of the three inputs left no trace of which state they were in:

    1. wago DB2 -- already addressable. The fetch URL is ?build=<b>, so the client build stamp is not a fingerprint, it is the recipe: pin the stamp and a bare clone re-fetches that exact build. This tier needed documenting, not fixing.
    2. CMaNGOS -- not addressable, and now hashed. The download URL ends releases/download/latest/, a moving tag with no version in it, so there is no release id to ask for or to record. New source_fingerprint() emits -- source <file> sha256:<12 hex> for these, wired into build-use-effects (proc rates), build-sets (the sqlite) and build-vendor-prices (the npc_vendor dumps, one line each, since the gate is their union). This is the tier that actually bit us: item 20905 lost its proc rate between two runs and nothing in the repo could show it.
    3. The TOG Tools walk -- not reproducible at all, so it states a policy instead. It is the output of an in-game item walk on one machine; no hash helps. build-core's files now say so outright, including that a bulk refresh must skip them rather than emit an empty one.

    A source that is absent is dropped, not written as missing -- a builder legitimately degrades without its optional CMaNGOS cache, and a header claiming a source it never read would be worse than one that is silent. Verified without touching Data/: fingerprints are stable across calls, an absent input emits no line at all, and a header() call with no sources= is byte-identical to before. Location: tools/itemdb_common.py, tools/build-{use-effects,sets,vendor-prices,core}.py.

  • The build stamp was a convention with no implementation, and ~78 generated files had none (findings 18/19). Every generated data file is supposed to record the wago build it came from -- that property is what makes a regenerated dataset auditable at all -- but nothing implemented it: header() took no build, so thirteen builders hand-wrote the line (three of them bypassing the helper) and the rest simply did not. The unstamped set was the majority of generated files: all 72 locale Names.lua / RandomProps.lua, plus ItemLocations.lua, Hidden.lua and RepLoot.lua. Names.lua did not even name which builder wrote it -- it could not, because no builder does; both callers go through itemdb_common.write_names, which sits 56 lines above header() in the module that owns the convention.

    header() now takes build= / detail= / note=, and every call site passes it -- the six that omitted the stamp entirely, and all fifteen that hand-wrote it, including the three which bypassed the helper altogether (build-req-levels and both halves of build-vendor-prices wrote their whole header with raw f.write, which is how the convention spread without ever becoming a mechanism). The stamp is now emitted in exactly one place, so omitting it takes deleting an argument rather than merely not thinking of one. Byte-identity was verified by running builders rather than reading them: build-sets (a header() migration) reproduced its file unchanged at +22/-22, and build-req-levels (a raw-f.write conversion) reproduced its six header lines character for character. client build None is never written -- build-rep-loot legitimately has no build when its wago enrichment is skipped, and says so, because absent is honestly unknown while None reads as a recorded fact. build-hidden.py was routed through the shared helper instead of gaining a fourteenth hand-written line, and it now also stamps the ATT removed-with-patch cutoff, a second unrecorded input the build alone does not determine. No shipped data changed in this commit -- the stamps appear on the next regeneration, which is deliberately a separate, reviewed change. Verified: header() unit-checked on all five paths, and re-running an un-migrated builder reproduced its file byte-identically. Location: tools/itemdb_common.py, tools/build-{hidden,rep-loot,item-sources,locales,core}.py.

  • Both halves of a split data file inherited a row count describing neither (finding 19). A builder emits one file and its header states that file's count; split-seasonal.py then divides the rows and copies the header verbatim into both halves. So Vanilla's ReqLevels.lua ships 10,544 rows in _core and 5,108 in _seasonal, and both headers say 15,652. The build stamp inherits correctly -- both halves really did come from that build -- and only the count is wrong, which is why the fix belongs in the splitter rather than in header(): the splitter is the only component that can tell the two fields apart, because it is the only one that knows the post-split totals. header() runs inside the builder, before either file exists.

    _recount corrects the count and never touches the stamp. It is deliberately conservative: it rewrites only when the pre-split total appears exactly once as a standalone number across the header, and only in -- comment lines, so a header that repeats the number or whose total collides with another figure (N of 24127 shipped items) is left alone rather than guessed at. Unit-checked on six cases including both refusals and the 156520-vs-15652 substring boundary. No shipped file is corrected yet -- that lands on the next deliberate split. Location: tools/split-seasonal.py.

    It matches the OTHER HALF's count as well as the pre-split total, and that is what makes it idempotent. rebuild sources both halves' structure from the base file, so on a second run the overlay inherits the base's already-corrected number; a pre-total-only match would find nothing, refuse, and leave the overlay stating the base's row count. Caught by running the correction twice rather than once -- with the single-candidate rule, run 2 rewrote Vanilla's seasonal ReqLevels header from 5108 to 10544. Mutation-checked both ways.

    Sets.lua is the one file that refuses, and the reason is worth recording: its _core half holds 172 sets and its overlay holds 287, because SoD sets exist only in the overlay -- the Era DB2 cannot produce them, which is why the splitter unions rather than recomputes. So the header's 172 is the base count and the pre-split total (459) appears nowhere in it. Refusing is correct; the overlay keeps a wrong 172 sets until a labelled-count fix, which is not attempted here.

  • Eight builders could not reach FROZEN_BUILD at all (finding 17, step 1). They resolved the wago build through client_for_version directly -- so the pin was not merely shadowed by a live client, it was never consulted, and a version whose client has moved on (the Anniversary client progresses 2.x -> 3.x -> ...) exited rather than falling back to it. build-classes, build-core, build-effects, build-equip-stats, build-factions, build-item-sources, build-sets and build-use-effects now resolve through wago_build_for_version, matching the other nine.

    Two consequences. It is what lets the pin ever become the default -- that change would otherwise have been invisible to exactly the builders that produce Sets.lua and UseEffects.lua. And seven of the eight become runnable on a machine without the era's client; build-core is the exception, because it also needs the TOG Tools walk SavedVariables, which no pin can substitute for. Verified: build-sets.py Wrath now reports "no installed client and no FROZEN_BUILD entry" instead of "no installed client", and a pinned Vanilla rebuild is byte-identical (+22/-22, unchanged). Location: the eight tools/build-*.py above.

  • Four builders could not be pinned to a build at all (finding 18). build-req-levels, build-vendor-prices, build-hidden and build-rep-loot accepted no --wago-build, so they always took whatever the default resolved to -- and that default is the live client's build, which moves. That is why the shipped dataset's odd files were odd: ReqLevels / SellPrices / VendorPrices are the outputs of exactly these builders, so they carry whatever build was current the day they ran while their pinnable neighbours do not. The outliers were outliers by capability, not by date. All four now take --wago-build, consulted first, matching the other twenty-one. Location: tools/build-{req-levels,vendor-prices,hidden,rep-loot}.py.

    This matters most in build-hidden, where the build feeds both the level-cap sweep and (via _client_patch) the ATT removed-with-patch cutoff -- so an unintended build changes which items the library refuses to serve. Its or DEFAULT_BUILD tail is deliberately left in place for now: removing it is a separate change, and it had to wait for the AttributeError fix below.

  • _client_patch guarded the wrong exception, and the hazard next to it was holding it shut (finding 19). except (ValueError, TypeError) handles a malformed build string; the likelier input is a missing build, and None.split(".") raises AttributeError, which was not caught. It could not fire only because build-hidden.py:148's or DEFAULT_BUILD tail guarantees a value -- so the silent wrong-era fallback already filed as a hazard was the thing preventing an uncaught exception in the same function, and removing it first would have converted a quiet wrong build into a crash inside the builder that decides what RankItems will serve. AttributeError added, with the ordering constraint written where the next person will hit it. The 999999 fallback is left alone deliberately and documented as what it is: not a "don't know" sentinel but the maximum cutoff, so an unparseable build would silently hide every removed-from-game item for every era. Changing which items are hidden is a data decision, not a bug fix.

  • Five weight-scale keys were hardcoded in the core (finding 10), reaching past RULES while every other per-expansion vocabulary difference already arrived through it. Each flavour now declares procKeys. Latent rather than live today, and the scenario is Wrath: it merged healing into spell power, so a Scoring/Wrath.lua keyed on ITEM_MOD_SPELL_POWER would have zeroed both the heal and the caster-damage routes for a second, independent reason.

  • Data/Mists/_core/Hidden.lua was on disk and named by no TOC (finding 5), so on a Mists client lib.hidden stayed empty and RankItems / search silently stopped filtering, over 13,066 items. One line in ItemDB_Mists.toc.

Testing

  • Tests/manifest_spec.lua (new) asserts every shipped .lua is named by some manifest: the direction toc_spec.lua cannot check, and the one that actually fails in practice. It invokes the shared verify-manifest.lua rather than reimplementing its walk, so the walk stays single-sourced. It deliberately does not test pipe:close()'s result -- Lua 5.1 gives file:close() no return contract, so a spec built on it would pass no matter what the tool said.
  • Around 24 specs added across the scoring surface, lifting LibItemDB-1.0.lua line coverage from a measured 60.09% to 70.62%. Every fix above was mutation-checked; the procKeys one two-sided, because a refactor whose indirection resolves to the same literals is invisible to the existing suite.
  • GetItemScoreBreakdown's "guaranteed by construction" equality with GetItemScore is now pinned by spec (finding 4). It was two hand-matched orchestrations, and the only spec touching it used a flask with a single stat, so the duplicated blocks had zero coverage.
  • Line coverage reached 100.00% on every shipped file -- LibItemDB-1.0.lua 1308/1308, ItemDB.lua, Integrations.lua, Scoring/Vanilla.lua and Scoring/TBC.lua. It was 79.02% on the library at the start of this pass (60.09% earlier in the entry); the suite is 346 specs. What the last 21% turned out to be is the point: GetSources, the whole loot browser (GetLootModules / GetLootCategories / GetLootSections), GetItemSet / GetSet, GetSetBonusEP, every BiS reader, LoadBiS, LoadScoreModel, Search's result assembly, GetClasses / GetSubClasses, GetRandomProperties and lib:LoadClasses -- all of them public read APIs a consumer calls directly, none of them executed once by the suite. Two live defects fell straight out of writing the specs (below). Fixture traps worth knowing are written into the spec file at each site: the harness plays a Horde character; LoadSources's items map uses a comma and its instances map a colon, and swapping them loads nothing and raises nothing; and a Search result is an array with a capped field on it, so an empty result is { capped = false } and never {}.

Bug Fixes -- stale lazy indexes

  • Two of the library's four lazy indexes were never invalidated, so a data file loading after anything had read one served stale answers for the rest of the session. LoadSources cleared _srcInstItems (instance -> items) but not _srcEncItems (boss -> items), which is built from the very same srcItems table; LoadRepLoot cleared nothing at all, leaving _repLootSrc (item -> reward sources) frozen at whatever the first GetSources call built. The visible effect: a boss's loot list missing every item added by a later file, and reputation/battleground rewards absent from GetSources entirely. Latent rather than live on a real client -- a TOC loads every _core file before a consumer can query anything -- but it is live for any consumer doing a runtime top-up, which LoadSources is documented to support, and it was live in the test suite, which is one shared Lua state. Both now clear their index, matching what LoadBiS already did for _bisIndex. Mutation-checked: restoring either omission reddens exactly the one spec written for it and nothing else. Location: LibItemDB-1.0.lua (LoadSources, LoadRepLoot).

Testing -- harness pin

  • Tests/wowapi moved from ff379c2 (2026-08-16) to 769c043 (2026-08-19). Every adoption entry in that range reads "Adopt: nothing" for a consumer in our position, with one exception: verify-libs.lua had been reporting a false green, counting every SKIP as a success and printing 8/8 libraries verified having loaded nothing, and the entry says to re-run it. Run correctly from the addon root (not the harness root, which is what the superseded instruction said) all three verify tools are genuinely clean here: 8/8 libraries with real method counts, 27/27 AceGUI widgets constructed and driven, and every env reset restoring every global it owns. Suite green at both pins.

Bug Fixes -- vendor prices

  • nil from GetVendorBasePrice was documented as proof that no vendor sells an item. It is not, and the claim contradicted the era caveat shipped alongside it. Caught by peer review (docs/AUDIT.md round 4, finding 3). Absence conflates four conditions and only the first is "no vendor sells it": the gate is a Wrath/Cata npc_vendor dump, so an item sold only by a Vanilla- or TBC-era vendor is absent while a vendor really does sell it; absence also covers a BuyPrice of 0 and an id the version doesn't ship. A consumer building a "not sold by vendors" label on nil would have been wrong for an unknown number of Classic items, silently.

    The wording is now "no vendor record" everywhere. It had propagated to five sites, found by grepping the phrase rather than trusting memory: the getter's summary line and docstring, the lib.vendorPrice state comment, the README API row, the CurseForge example, and the spec's own test name — which asserted the false contract in its title, the version most likely to be taken as settled by the next reader. Documentation only; no behaviour changed. Location: LibItemDB-1.0.lua, README.md, docs/Curseforge_Description.html, Tests/libitemdb_spec.lua.

  • Why nil covers both "no record" and "unknown item", stated properly. It was justified by caller convenience; the real reason is that 0 is unreachable by construction at two independent layers — build-vendor-prices.py requires BuyPrice > 0, and LoadVendorPrices rejects 0, negatives and non-numerics — so there is no third state to represent. GetRequiredLevel differs only because its 0 is a real domain value ("no requirement"). The asymmetry is principled.

Known Limitations

  • The shipped number is the BASE price — what a NEUTRAL player pays. Reputation discounts are applied server-side at purchase. Verified rather than reasoned about: Classic Era's MerchantFrame.lua:200 takes price straight from GetMerchantItemInfo and there is no discount arithmetic anywhere in the UI, so the client only ever receives an already-discounted figure and only while a merchant is open. There is no client-side table of discount tiers. The API is named GetVendorBasePrice rather than GetVendorPrice precisely so a caller notices — the failure mode is a plausible wrong number, not an error.

  • The npc_vendor dumps are Wrath (AzerothCore) and Cata (TrinityCore) era. No Vanilla or TBC emulator dump is available, so a Vanilla build is gated on Wrath-era vendor inventories. Intersecting with the ids each version ships keeps out items that don't exist in the era, but existence is not availability: an item a Wrath vendor stocks may not have been vendor-sold in 1.12, and an item vendor-sold only in Vanilla is missing entirely. Stated here because nothing in the pipeline can detect it.

  • Coverage is 862 items (Vanilla) and 1,708 (TBC), not the 3,023 the request predicted. That figure counted vendor-sold items with a BuyPrice across every expansion's merged DB2; ours additionally intersects with the ids each version actually ships, and most of the remainder are Wrath/Cata items a Classic client has no core row for. Serving a price for an item GetInfo returns nil for would be the worse outcome.


[v0.6.0] (2026-08-06) - A third-party tooltip bridge (ATT / TSM / Auctionator / SmexyMats); recipe-scroll data built here and handed to LibProfessionDB

New Features

  • Integrations.lua — a bridge, not a re-implementation. One place for reading what another addon already knows so a consumer's own tooltip carries the same lines the player sees elsewhere: their wording, their formatting, their icons, passed through unmodified. A registry rather than a pile of functions, because the list is expected to grow — adding the next addon is a table entry.

    The rules are enforced, not aspirational: never a hard dependency (several of these load after this library, so detection is at call time, never a load-time capture); never raise (these are semi-public surfaces on code nobody here controls, so every call is wrapped and a provider that has moved drops out while the rest still answer); never read their data into ItemDB's own tables (if a fact is worth shipping it comes from DBC like everything else); and nothing is called automatically — the consumer owns its tooltip.

    lib:GetAvailableIntegrations() says which are usable right now.

  • lib:ApplyExternalTooltipHooks(tooltip, scriptType) — the universal bridge. Replays every installed addon's tooltip handlers against your tooltip, so it works for an addon whether or not it exposes any API. That is the only way to reach RecipeMaster (whole namespace is the addon-private vararg, with zero _G writes) and Leatrix Plus (no public API).

    HookScript composes — the frame's script becomes a function calling the previous handler then the new one — and GetScript hands that whole chain back. Every handler takes the tooltip as its argument and writes to that argument, so invoking the chain with a different tooltip routes all of them onto it. The setter (SetItemByID / SetSpellByID) is what fires the script; it fires on your frame, which has none of their hooks because they hooked GameTooltip.

    GameTooltip is never read, written, shown, hidden or re-owned — a parallel fan-out rather than a scratchpad, so nothing flickers and there is nothing to save and restore. A spec puts a metatable trap on it and asserts nothing beyond GetScript is ever even looked up.

    Two hard requirements, both refused up front rather than left to fail inside someone else's addon: the tooltip must be a named frame inheriting GameTooltipTemplate (RecipeMaster's dedup reads _G[tooltip:GetName().."TextLeft"..i]), and it must be populated before the call.

    Verified from source rather than assumed: Blizzard sets no Lua OnTooltipSetItem / OnTooltipSetSpell handler on Classic Era — its population happens in C during the setter — so the replayed chain is purely addon handlers and cannot duplicate Blizzard's own lines. Not yet verified in a running client, which no offline spec can do.

  • lib:AttachExternalRecipeInfo(tooltip, spellID) — attaches AllTheThings' own lines to any tooltip, keyed by spell, so it works for a recipe with no teaching item at all. Built from ATT's Classic path (src/Modules/Tooltip.lua:1214, SearchForField) rather than its retail one (:831, SearchForObject) — the two hand over different search functions and only the first is what a Classic client runs. AllTheThings is ## OptionalDeps in all five TOCs and never a hard dependency; every hop is feature-detected, the call is wrapped, and it returns false in every failure mode without raising.

    AttachTooltipSearchResults returns nothing (Tooltip.lua:789-812), so "did anything attach" is measured as a NumLines delta — true means lines actually landed, which is what a caller needs to decide whether to add its own "no data" line. The spell-keyed lookup is sound by construction: src/Cache.lua:895-898 shows fieldConverters.recipeID also calls cacheSpellID, so every ATT recipe row is dual-indexed under spellID.

    ItemDB reads no data from ATT and must not start. If ATT has a fact this suite needs, it gets sourced from DBC like everything else.

  • Integrations.lua — one place for third-party addon bridges, as a registry rather than a pile of functions, since the list is expected to grow. lib:GetAvailableIntegrations() says which are usable right now (resolved at call time — several of these load after this library); lib:GetExternalPrices(itemLink) returns each price addon's own numbers, in copper, unmodified, so a consumer's tooltip can show the figure the player already sees elsewhere.

    TradeSkillMaster and Auctionator (Auction / Vendor / Disenchant) are value-fetch only — neither can draw into a tooltip, so the consumer renders them. Both raise on bad arguments in real use, so every call is wrapped; a missing, misconfigured or moved provider drops out and the others still answer.

  • TSM prices are enumerated, not hard-coded. An earlier cut asked TSM for three sources it had been told about by name. It now asks TSM_API.GetPriceSourceKeys() what this install actually registers, and pairs each key with TSM's own localized label from GetPriceSourceDescription, so a line reads as it does in TSM's own tooltip and in the player's language. A source the player has from a TSM module we have never heard of, or one TSM adds in a later build, appears with no change here.

    Counted against the installed client rather than estimated: 41 sources across RegisterSource calls — AuctionDB 9, Accounting 9, External 8, Item 7, Operations 5, Crafting 3.

    Whether a number is money is an allow-list, not a guess. DBRegionSaleRate (0.038) and DBRegionSoldPerDay (0.076) are not copper, and money-formatting them renders 0c — silently wrong, in exactly the place a player would trust it. So would ItemLevel, MaxStack, NumInventory, ItemQuality, RequiredLevel, NumExpires and SaleRate. Those nine are the complete non-money set; the other 32 get TSM_API.FormatMoneyString and everything else is handed back raw with isMoney nil. The split was verified exhaustive against this TSM build — 32 + 9 = 41, no key unaccounted for.

    It also fails safe in the direction that matters: an unrecognised key — a TSM module added later — renders as its plain value rather than as fabricated gold.

    Recorded as a closed finding rather than a request, because we do not own TSM and cannot change it: TSM does hook GameTooltip, but its handler returns immediately unless the tooltip is in its own private.tooltipRegistry (Tooltip/TooltipWrapper.lua:113). That is correct of them, and it means TSM is the one bridge here whose rendering cannot be replayed — only its values.

  • lib:GetExternalMaterialInfo(itemID, itemLink) — SmexyMats' reagent data. Which professions use a material (SmexyMats.Reagents) and where it comes from (Sources + Vendor), plus the expansion. A separate call from prices because it answers a different question.

    The strings pass through unmodified, markup included — SmexyMats embeds profession icons as |T…|t when the user has icons enabled and plain names when they do not, read from its own SmexyMatsDB.profile. Reproducing what that player already sees is the entire point, so nothing here strips or re-cases them. An item that is not a material returns nil rather than empty strings, so a consumer has one check instead of two.

  • Deliberately NOT bridged: RecipeMaster — and it does not need to be. It hooks GameTooltip's OnTooltipSetItem and OnTooltipSetSpell, plus ItemRefTooltip (Source/Handlers/TooltipHandler.lua:165,176,187), so a consumer using GameTooltip with SetItemByID / SetSpellByID gets it automatically in its own styling — and the OnTooltipSetSpell hook is what covers a trainer-taught recipe with no scroll item at all.

    What decides bridgeability is the namespace, not the tooltip, which is worth recording because getting that backwards cost a wrong call here first: SmexyMats is LibStub("AceAddon-3.0"):NewAddon("SmexyMats", …), a real global, so its data functions are reachable; RecipeMaster is local addonName, rm = ... with zero _G writes anywhere in its source, so nothing is. Same tooltip mechanism, opposite reachability.

    Two assumptions checked and corrected while investigating: TSM's tooltip does not come from TradeSkillMaster_AppHelper (28 lines, no tooltip code — it is the app's auction data), and vendor price needs no addon, since GetItemInfo returns sellPrice natively.

Architecture — recipe-scroll data went to LibProfessionDB before release

The recipe→teaching-item work raised by TOGProfessionMaster was developed in ItemDB and moved to LibProfessionDB-1.0 (its MINOR 8) before either shipped, so no released version of ItemDB ever carried it and no consumer had to migrate. It is keyed by craft spell id, which is how that library already keys recipes, and a recipe tooltip is recipe-shaped rather than item-shaped: recipes reference items, items never reference recipes, so ProfessionDB → ItemDB is the safe direction.

LibItemDB-1.0 is at MINOR 20; GetLink / GetName are unchanged and are what a consumer calls on the id ProfessionDB returns.

The findings are recorded here because they outlive the code that moved:

  • The mapping is a DBC join, not a name match. ItemEffect alone does not answer it — most scrolls teach via a generic intermediary "Learning" spell, so the missing hop is SpellEffect[SpellID, Effect = 36].EffectTriggerSpell. Measured against the English-prefix matcher it replaced: the matcher found zero links the join misses, the join found 35 the matcher missed, and the join is locale-independent where the prefix list was English-only. TOGProfessionMaster has since retired that matcher at source.
  • ItemSparse.Description is the wrong field for a scroll's Use: line — populated for 60 of 1,073 real Vanilla scrolls, against the teaching spell's Spell.Description_lang at 1,022.
  • TBC has no per-recipe teaching spell at all, so no Use: sentence exists to derive there: ItemEffect carries the generic spell 483 "Learning" (empty description) plus the craft spell.
  • requiredSkill was wrong and is now dropped, not fixed. It read SkillLineAbility.MinSkillLineRank, a floor that defaults to 1, so Smelt Truesilver rendered as "Requires Mining (1)" against a real requirement of 230 — found in game rather than offline. ProfessionDB carries the correct value on the recipe itself, so a second copy could only drift.
  • Deleting the source needs the destination verified first. ProfessionDB's hand-over listed AttachExternalRecipeInfo as moved and it had not been — no such function, no AllTheThings reference, no OptionalDeps anywhere in that repo. It stays here until that library actually has it. ItemDB's copies were untracked in git, so a delete-on-trust would have had no history to recover from.

Testing

  • TOC sweep across all five manifests, and ItemDB_BCC.toc is now ItemDB_TBC.toc. The Title already read "TBC"; only the filename disagreed.

    Settled from Blizzard's own source rather than from popularity, which is how it should have been settled the first time: F:\Blizzard API Docs ships four flavour trees, and Blizzard's own addons use _TBC.toc in all of them — 11-12 of them per Classic tree — with not a single _BCC.toc anywhere in any tree. _BCC is an addon-community convention (from "Burning Crusade Classic") that the client also accepts, which is exactly why it spreads. Renamed with git mv so history follows; Tests/libitemdb_spec.lua, Scoring/TBC.lua, CLAUDE.md and the replication script's comment moved with it. References inside released CHANGELOG entries, docs/TBC-Parity.md and docs/DEPENDENCY_CONTRACTS.md were deliberately left alone — they are historical or append-only, and record what the file was called at the time.

    CLAUDE.md also carried three stale Interface numbers (11508/20505/50503 against the real 11509/20506/50504), which is worse than a wrong filename: it is the reference a future session trusts when bumping a TOC.

  • New Tests/toc_spec.lua — the manifests had no coverage of any kind. Nothing else in the suite reads a TOC, so a file missing from one, or one naming a file that no longer exists, tested perfectly green until a client loaded it. Twelve assertions: every listed file exists on disk; no Mainline manifest exists (deliberate — the captures do not cover Retail); the core block loads LibStub → library → Integrations.lua in that order in every flavour, because Integrations.lua resolves the library at file scope and returns early if it is not registered yet, which would silently take every bridge with it; exactly one Scoring/ file per flavour and only where rules exist; the four bridged addons declared OptionalDeps and never as hard dependencies; Interface numbers per flavour; Notes/Author/Category/project-id identical across manifests; ItemDB-v0.8.0 never hardcoded; and no empty directive.

    Writing it caught a real structural fact I had wrong twice: ItemDB.lua loads before the data files, not last, and that is correct — the bootstrap only wires a PLAYER_LOGIN handler and never reads the database at load. The invariant that matters is that the core is a contiguous prefix ahead of all Data\ files, since every data file calls into the library as it loads. Wrath and Cata ship no data at all and are now flagged placeholder explicitly, so a flavour that ever loses its data fails loudly instead of quietly reading as "not captured yet".

  • Release docs corrected and completed. docs/Curseforge_Description.html listed the bridge as AllTheThings / TSM / Auctionator / SmexyMats and omitted Recipe Master and Leatrix Plus — the two the universal fan-out exists for, since neither exposes anything to call. That contradicted README.md, which had them right. Verified before syncing rather than trusting either doc: Leatrix Plus does GameTooltip:HookScript("OnTooltipSetItem", ShowSellPrice) (Leatrix_Plus.lua:8502), so the fan-out genuinely reaches it. Both the feature list and the v0.6.0 entry now name all six and explain the replay-what-they-already-do model in player terms.

    README.md gained a Testing section — how to run the suite, the no-busted/no-CI rules, how to read the coverage number (including that a guard counts as covered once its condition is evaluated, so 100% is a floor rather than proof), and the three standing append-only files. Both READMEs now also record that the four bridged addons are ## OptionalDeps in every TOC, present only so the client loads them first, never as a requirement.

  • ItemDB.lua had never been loaded offline — 0 of 13 lines. Tests/itemdb_spec.lua is new and takes it to 13/13 (100%). It is only a bootstrap, but it decides the identity this addon reports to the guild and it feature-tests a client API, which is the shape that fails silently rather than loudly. Twelve tests pin: the version is read through C_AddOns.GetAddOnMetadata with the bare global staged absent (verified in the Classic Era tree — 0 bare call sites against 5 namespaced, declared in AddOnsDocumentation.lua), so reaching for the deprecated name fails the suite rather than passing quietly; metadata is looked up under the folder name ItemDB while the reported identity is deliberately LibItemDB, which is the pair most likely to be "corrected" into a bug later; the "dev" fallback for an unpackaged working copy where ItemDB-v0.8.0 was never substituted; UnregisterAllEvents being the only thing that stops a second run; the silent flag on the LibStub lookup; and no raise when VersionCheck is missing, present-without-Enable, or when LibStub itself is absent.

    The lookup happening at login rather than file scope is pinned too — that is what lets VersionCheck load after this addon without the check going dark.

Suite 141 passed, 0 failed, with Integrations.lua at 158/158 executable lines (100%) — measured with Tests/wowapi/coverage.lua, not asserted. 40 of the 129 cover the bridge: 8 on AttachExternalRecipeInfo, 14 on the price providers, 8 on the universal fan-out, 8 on GetExternalMaterialInfo, plus the TOC and GameTooltip-safety guards.

The failure modes are what they are built around, since every one of these calls a surface nobody here controls: the provider is absent, the provider has moved, the provider raises (TSM and Auctionator both validate arguments by throwing), the provider returns 0 or a negative, and — for the fan-out — a handler raising midway must not discard the lines an earlier one already added. One spec puts a metatable trap on GameTooltip and asserts nothing beyond GetScript is ever looked up.

What these tests cannot do is confirm any of it works in game. They drive our own stubs of the five addons, shaped by reading their source, so a misreading is ratified rather than caught — and this session misread both SmexyMats and TSM before correcting. Live verification is docs/DEPENDENCY_CONTRACTS.md §9, still open with TOGProfessionMaster.

Adopted docs/AUDIT.md, the harness's peer-review protocol — the inverse of a contract: a contract is raised here and answered by the harness, an audit finding is raised by a review session and answered by us. The first cut was built against the older docs and got two rules wrong, corrected on the harness's own notice: it had a ## Fixed section, which means a resolved finding gets moved — a slower form of deleting it, when the value is the failure scenario sitting next to the code it describes. Findings now stay in place and state lives only in the Status table.

Earlier in this cycle the Tests/wowapi pin moved from 4161af8 (2026-07-19) to 3206e75 (and now f2b0114), which surfaced three real gaps in the shared env — all raised and fixed upstream:

  • strsplit dropped a trailing empty field. Empty middle fields were already correct, which is why it survived inspection. In a packed field<US>field<US>… record a dropped trailing empty changes the field count without changing anything visible. This spec file's local copy was hiding it; deleting the copy proved our decode never depended on it.
  • GetItemQualityColor / GetItemClassInfo / GetItemSubClassInfo did not exist in the env. The library captures all three into file-scope locals at load, so a consumer not staging its own took nil copies permanently. Our spec had been staging its own — which is exactly what stopped the gap being noticed.
  • One assertion changed as a result: plate head armour now checks GetItemType against the real subclass name "Plate" rather than the "Sub4.4" a local stub had fabricated.

Older releases (v0.5.0 and earlier) are in CHANGELOG_ARCHIVE.md.

This mod has no additional files