promotional bannermobile promotional banner

LibItemDB

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

ItemDB-v0.7.1

File nameItemDB-ItemDB-v0.7.1.zip
Uploader
PmptastyPmptasty
Uploaded
Aug 22, 2026
Downloads
575
Size
18.7 MB
Flavors
MoP ClassicClassic TBCClassic
File ID
8705247
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.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.7.1 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.7.1 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.