ItemDB-v0.7.1
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
GetSlotRankingwith nopoolwalked and re-scored the ENTIRE database, once per slot. It built an array of every id inself.coreand handed all 17,604 of them toRankItems, whichunpackCore'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.slotIndexnow buckets core byequipLoconce, lazily, and each ranking scans only the items that can possibly match.LoadCoreinvalidates it, which is the part that could go wrong silently -- a_seasonaloverlay or a second addon'sLoadCorelanding 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. Anilslot ("rank everything") still walks core, since no per-slot bucket can serve it. Public API, signature and results are unchanged, so there is noMINORbump 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, sotable.sortbroke 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
GetStatsallocated three tables per call to merge two extras. It walkedipairs { self.equipStats[id] or "", self.effects[id] or "" }-- an array literal per call -- and ran each blob throughdecodeStatsinto a throwaway table just topairs()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 newmergeStatslayers a blob straight into the table that is being built: 5.5 us -> 3.8 us, and no array. Theor ""on each extra was load- bearing in the old shape rather than defensive -- a nil FIRST element endsipairsimmediately, 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.Searchcarried 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.pylet a partial wago export overwrite 41,777 shipped Traditional Chinese names with English, and exited 0 (docs/AUDIT.mdfinding 34). Thetry/exceptaround 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 Mists5.5.4.68806rebuild: 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.luaand sixNames.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._coreneeded no rebuild on either flavour, which is what measuring first established: all 14 TBC wago files already carry the pin2.5.6.68941and both Mists wago files5.5.4.68806. The 16 TBC / 17 Mists files stamped20505/50503are walk-sourced interface stamps and correctly outside the mosaic -- they are not to be "fixed".enUS/Names.luacarries no stamp on any version and that is also correct: it is the walk locale, written bybuild-core.pyand deliberately skipped bybuild-locales.py.Data/Vanilla/_core/ReqLevels.luashipped UNSPLIT while_seasonal/ReqLevels.luaalso existed, so 5,108 seasonal ids were defined twice (docs/AUDIT.mdfinding 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, soGetRequiredLevelanswered for seasonal items on a non-seasonal realm -- the one thing thelib: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 Vanillato prove an unrelated change was inert regeneratedSellPrices.luaunsplit (13,771 -> 18,140 rows) -- which is the standing hazard that any builder rerun reverts the partition andsplit-seasonal.pymust 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-manifestpassed, 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.mdpoints 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 owndocs/LIBRARY_CONTRACTS.mdfor 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-001records the half of the report ItemDB cannot fix. Dibs rebuilds itsattackOptsequipped-weapon context (twoGetInfo+ twoGetStats) andLoadout:EquippedEP(a fullGetItemScoreon 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.md1.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_WORLDwas missing from the invalidator ItemDB specified.GetInventoryItemIDanswers 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:PlayerClassSpeccached its talent scan only when points were spent -- so every character below level 10 re-walked all three talent trees through the deprecatedSpecializationshim, once per item, viaWeights: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 shipsItemDB_BCC.toconly.ItemDB_TBC.tocis deleted." ItemDB reversed that since; the repo shipsItemDB_TBC.tocand no_BCC, pinned byTests/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-BCCwas dropped in Patch 2.5.5 -- so the underscored_BCC.tocwas 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 inLIBRARY_CONTRACTS.mdwith that reasoning written in the file; MD050 stays on, since it still catches something.
- 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
Tests/HARNESS_CONTRACT.md: the harness'senv/wow.luaflavour-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 owndocs/TOC.mdcontradicts. Raised as a comment correction only -- thebcc = 5alias 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 publishesCHANGELOG.mdverbatim 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_THROWNweapon -- 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 readsGetRequiredLevel. That half stands; what was published alongside it did not. A link was named on the Dibs side -- something in the ranged slot makesweaponContext(equippedRangedID())do real work per row instead of returningnilat 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
GetItemScorenow honoursopts.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 (schoolGatedinScoring/Vanilla.luaandScoring/TBC.lua), not hardcoded in the core, and it is matched POST-alias soITEM_MOD_SPELL_POWERandITEM_MOD_SPELL_DAMAGE_DONEare both covered by one entry.GetItemScoreBreakdowngates identically through the same shared helper, and a gated stat is omitted frompartsrather 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.mdfinding 20). Location:LibItemDB-1.0.lua,Scoring/Vanilla.lua,Scoring/TBC.lua.
Bug Fixes
Factions.luasilently lost 155 items whenever it was rebuilt on a current client. wago renamed theFactiontable's race-mask columns at build 1.15.9.68808: what wasReputationRaceMask_0..3is nowReputationRaceMasks0_0,ReputationRaceMasks0_1and so on.race_mask()knew two spellings and neither matched, so it returnednilfor every faction, thefactionID -> sidemap came back empty, and every item whose side comes only fromMinFactionIDdropped 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 teachingrace_mask()the array spelling, and by making the silence impossible: the builder now exits with a message naming the columns it did find if theFactiontable yields no sides at all, or ifItemSparseyields 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'sFactions.luahad never been regenerated since, so it kept shipping the broken reader's output while its header read2.5.6.68941and 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 WolfandHorn of the Red Wolfas Horde,Black Stallion Bridle,Pinto BridleandChestnut Mare Bridleas Alliance -- which are precisely the items whose side comes from a race mask and nothing else. Until nowFactionUsablereturned 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.luashipped 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:GetRequiredLevelanswered0-- which is the real domain value meaning "no requirement", not a miss -- for every one of them, andGetInfo'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--statdiff reported +5,110 lines on a file whose header count had not moved, which is a contradiction;--numstatgave 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.pycould 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. Butpre_totalis 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 VanillaNames.luawere emitted with 23,448 names, the overlay held 679 ids that build no longer has, sopre_totalwas 24,127, neither candidate matched, and all 22 files shipped a header claiming 23,448 against real counts of 17,604 and 6,523.enUSescaped only becausebuild-locales.pydoes 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 VanillaNames.luanow 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.mdfindings 16-19) to a single build.Hidden.luacarries 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, andRepLoot.lua,ItemLocations.luaand all 24Names.luagained 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 earlierpm=change set, not this rebuild. Location:Data/Vanilla/.ItemDB.tocnow listsData\Vanilla\_seasonal\ItemLocations.lua. The rebuild's seasonal split created that file for the first time and the TOC lists the_seasonalfiles by hand, so it shipped named by no manifest and would have loaded on nobody -- item locations silently missing for every seasonal item. Caught byverify-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).
sccame frommask >= 0x10000-- a magnitude compare on a bitfield -- written out twice, as a function inbuild-use-effects.pyand re-implemented inline inbuild-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", while0x40000(DEAL_HARMFUL_PERIODIC, a DoT tick) and0x100000(TAKE_ANY_DAMAGE) read"spell".itemdb_common.proc_scopenow tests bits and answersspell/any/periodic/taken; a mask carrying both cast and swing bits resolves toanydeliberately, 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 fromwago_cacheplus 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'sscwas checked against the classifier afterwards: 504 rows, 0 disagreements. Final distribution:any438,spell34,taken29,periodic3. 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.
castsPerSechad 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 CMaNGOScreature_template.MeleeBaseAttackTimeover 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 fromSpellEffect.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_SECassigns 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=0x28andpm=0x2A8areTAKE_MELEE_SWING|TAKE_MELEE_ABILITY(|TAKE_RANGED_*) with0x100000clear. 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 classifiedsc="any"and therefore rated by the wearer's own swing rate.PROC_ON_TAKENnow covers the wholeTAKE_*half of the enum. Location:tools/itemdb_common.py.The proc rate rule is now one sentence, and it covers all four scopes.
hitsPerSecis the only rateopts.attackcan 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.scoreProcEffectsgainsticksPerSec(DoT/HoT ticks; falls back tocastsPerSec, since a tick implies you cast the DoT) andhitsTakenPerSec(incoming attacks, a tanking number; falls back to the model'shitsPerSec). 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:periodicandtakenwould have fallen to theelsebranch 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.0MINOR 22. WithGetVendorBasePricebelow, 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
GetItemInfoalready 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 returnsnilfor every field and forces aGET_ITEM_INFO_RECEIVEDretry loop. That is the exact rationaleGetRequiredLevelalready 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'snpc_vendorintersection is for. Consequence:nilfromGetVendorSellPriceis a clean statement (the item has no sell value), unlikenilfromGetVendorBasePrice, 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
discountreferences are the in-game store, transmog and item upgrade. The mechanic is entirely server-side.Tests/libitemdb_spec.luacarries 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, ornil.LibItemDB-1.0MINOR 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.GetItemInforeturns what a vendor pays you; the client has that natively. It has no buy price at all — checked rather than assumed, there is nobuyPricefield anywhere inItemDocumentation.lua, and the only client route isGetMerchantItemInfo(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.BuyPricesupplies the price; emulatornpc_vendorsupplies whether a vendor sells it at all.BuyPriceis roughlysellPrice * 4and 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 ismaxcount == 0 AND ExtendedCost == 0(unlimited stock, bought with gold), which is the same reasoning Auctionator uses when it caches onlynumAvailable == -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.luaholds 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.luamadesplit-seasonal.pypartition 4,369 Vanilla rows intoData/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 intoitemdb_common.py.build-req-levels.pyhad the only copy;build-vendor-prices.pyneeded 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 existingatt_categories_dir(). It prefers a localtools/emulator_data/if one exists. The relevant input is 2.7 MB across two*_npc_vendor.sqlfiles, not the ~459 MB the wholeemulator_datadirectory 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.luais indistinguishable at runtime from "no vendor sells anything": the library would answernilfor 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.luaandScoring/TBC.luashippedepPerHPS = 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:epPerHPSis healing power per 1 HPS, and Classic's direct-spell coefficient iscastTime / 3.5, so+Hhealing power yieldsH / 3.5HPS 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 priceITEM_MOD_SPELL_DAMAGE_DONEwith neitherDPS_MAINHANDnorDPS_RANGED, while shamanenhancementhasDPS_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.epPerRawDPSwas 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. Now3.5by the same coefficient rule (direct damage is alsocastTime / 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: ansc="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.0MINOR 23.hitsPerSecwas one number doing three jobs --Scoring/Vanilla.luacalls it a "default proc trigger rate", the library calls it "triggering hits/sec", andGetItemScoretakes it fromopts.attack, which is a paperdoll and can only report weapon swings. So the moment a consumer wired the paperdoll (the documented intent) everysc="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 offmodel.castsPerSecwhere the flavour declares one, and off the model'shitsPerSec-- 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 thatopts.attackcan 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 indocs/AUDIT.mdrather than guessed at.A proc's trigger scope
scis decided by a MAGNITUDE test on a BITFIELD (finding 15).proc_scopereads as "does this mask have the spell bit";mask >= 0x10000does 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 labelssc="spell"share no common bit: Shiffar's Nexus-Horn0x14000(genuinely on-cast), Timbal's Focusing Crystal0x240000(a DoT-tick proc) and Battlegear of Might's 5-piece0x100000-- 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.procFlagsis0for these spells, meaning the emulator defers to the very field we already read. What shipped instead is the raw mask, aspmbesidesc, inSets.luaandUseEffects.luaon both flavours (LibItemDB-1.0MINOR 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.scis 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:
- wago DB2 -- already addressable. The fetch URL is
?build=<b>, so theclient buildstamp 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. - 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. Newsource_fingerprint()emits-- source <file> sha256:<12 hex>for these, wired intobuild-use-effects(proc rates),build-sets(the sqlite) andbuild-vendor-prices(thenpc_vendordumps, 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. - 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 aheader()call with nosources=is byte-identical to before. Location:tools/itemdb_common.py,tools/build-{use-effects,sets,vendor-prices,core}.py.- wago DB2 -- already addressable. The fetch URL is
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 localeNames.lua/RandomProps.lua, plusItemLocations.lua,Hidden.luaandRepLoot.lua.Names.luadid not even name which builder wrote it -- it could not, because no builder does; both callers go throughitemdb_common.write_names, which sits 56 lines aboveheader()in the module that owns the convention.header()now takesbuild=/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-levelsand both halves ofbuild-vendor-priceswrote their whole header with rawf.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(aheader()migration) reproduced its file unchanged at +22/-22, andbuild-req-levels(a raw-f.writeconversion) reproduced its six header lines character for character.client build Noneis never written --build-rep-lootlegitimately has no build when its wago enrichment is skipped, and says so, because absent is honestly unknown whileNonereads as a recorded fact.build-hidden.pywas 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.pythen divides the rows and copies the header verbatim into both halves. So Vanilla'sReqLevels.luaships 10,544 rows in_coreand 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 inheader(): 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._recountcorrects 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 the156520-vs-15652substring 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.
rebuildsources 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 seasonalReqLevelsheader from5108to10544. Mutation-checked both ways.Sets.luais the one file that refuses, and the reason is worth recording: its_corehalf 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's172is the base count and the pre-split total (459) appears nowhere in it. Refusing is correct; the overlay keeps a wrong172 setsuntil a labelled-count fix, which is not attempted here.Eight builders could not reach
FROZEN_BUILDat all (finding 17, step 1). They resolved the wago build throughclient_for_versiondirectly -- 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-setsandbuild-use-effectsnow resolve throughwago_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.luaandUseEffects.lua. And seven of the eight become runnable on a machine without the era's client;build-coreis the exception, because it also needs the TOG Tools walk SavedVariables, which no pin can substitute for. Verified:build-sets.py Wrathnow 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 eighttools/build-*.pyabove.Four builders could not be pinned to a build at all (finding 18).
build-req-levels,build-vendor-prices,build-hiddenandbuild-rep-lootaccepted 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/VendorPricesare 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. Itsor DEFAULT_BUILDtail is deliberately left in place for now: removing it is a separate change, and it had to wait for theAttributeErrorfix below._client_patchguarded 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, andNone.split(".")raisesAttributeError, which was not caught. It could not fire only becausebuild-hidden.py:148'sor DEFAULT_BUILDtail 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 whatRankItemswill serve.AttributeErroradded, with the ordering constraint written where the next person will hit it. The999999fallback 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
RULESwhile every other per-expansion vocabulary difference already arrived through it. Each flavour now declaresprocKeys. Latent rather than live today, and the scenario is Wrath: it merged healing into spell power, so aScoring/Wrath.luakeyed onITEM_MOD_SPELL_POWERwould have zeroed both the heal and the caster-damage routes for a second, independent reason.Data/Mists/_core/Hidden.luawas on disk and named by no TOC (finding 5), so on a Mists clientlib.hiddenstayed empty andRankItems/ search silently stopped filtering, over 13,066 items. One line inItemDB_Mists.toc.
Testing
Tests/manifest_spec.lua(new) asserts every shipped.luais named by some manifest: the directiontoc_spec.luacannot check, and the one that actually fails in practice. It invokes the sharedverify-manifest.luarather than reimplementing its walk, so the walk stays single-sourced. It deliberately does not testpipe:close()'s result -- Lua 5.1 givesfile: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.lualine coverage from a measured 60.09% to 70.62%. Every fix above was mutation-checked; theprocKeysone two-sided, because a refactor whose indirection resolves to the same literals is invisible to the existing suite. GetItemScoreBreakdown's "guaranteed by construction" equality withGetItemScoreis 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.lua1308/1308,ItemDB.lua,Integrations.lua,Scoring/Vanilla.luaandScoring/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,GetRandomPropertiesandlib: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'sitemsmap uses a comma and itsinstancesmap a colon, and swapping them loads nothing and raises nothing; and aSearchresult is an array with acappedfield 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.
LoadSourcescleared_srcInstItems(instance -> items) but not_srcEncItems(boss -> items), which is built from the very samesrcItemstable;LoadRepLootcleared nothing at all, leaving_repLootSrc(item -> reward sources) frozen at whatever the firstGetSourcescall built. The visible effect: a boss's loot list missing every item added by a later file, and reputation/battleground rewards absent fromGetSourcesentirely. Latent rather than live on a real client -- a TOC loads every_corefile before a consumer can query anything -- but it is live for any consumer doing a runtime top-up, whichLoadSourcesis documented to support, and it was live in the test suite, which is one shared Lua state. Both now clear their index, matching whatLoadBiSalready 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/wowapimoved fromff379c2(2026-08-16) to769c043(2026-08-19). Every adoption entry in that range reads "Adopt: nothing" for a consumer in our position, with one exception:verify-libs.luahad been reporting a false green, counting everySKIPas a success and printing8/8 libraries verifiedhaving 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
nilfromGetVendorBasePricewas 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.mdround 4, finding 3). Absence conflates four conditions and only the first is "no vendor sells it": the gate is a Wrath/Catanpc_vendordump, so an item sold only by a Vanilla- or TBC-era vendor is absent while a vendor really does sell it; absence also covers aBuyPriceof 0 and an id the version doesn't ship. A consumer building a "not sold by vendors" label onnilwould 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.vendorPricestate 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
nilcovers both "no record" and "unknown item", stated properly. It was justified by caller convenience; the real reason is that0is unreachable by construction at two independent layers —build-vendor-prices.pyrequiresBuyPrice > 0, andLoadVendorPricesrejects0, negatives and non-numerics — so there is no third state to represent.GetRequiredLeveldiffers only because its0is 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:200takespricestraight fromGetMerchantItemInfoand 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 namedGetVendorBasePricerather thanGetVendorPriceprecisely so a caller notices — the failure mode is a plausible wrong number, not an error.The
npc_vendordumps 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
BuyPriceacross 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 itemGetInforeturnsnilfor 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_Gwrites) and Leatrix Plus (no public API).HookScriptcomposes — the frame's script becomes a function calling the previous handler then the new one — andGetScripthands 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 hookedGameTooltip.GameTooltipis 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 beyondGetScriptis 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/OnTooltipSetSpellhandler 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.AllTheThingsis## OptionalDepsin 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.AttachTooltipSearchResultsreturns nothing (Tooltip.lua:789-812), so "did anything attach" is measured as aNumLinesdelta —truemeans 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-898showsfieldConverters.recipeIDalso callscacheSpellID, so every ATT recipe row is dual-indexed underspellID.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 fromGetPriceSourceDescription, 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
RegisterSourcecalls — 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) andDBRegionSoldPerDay(0.076) are not copper, and money-formatting them renders0c— silently wrong, in exactly the place a player would trust it. So wouldItemLevel,MaxStack,NumInventory,ItemQuality,RequiredLevel,NumExpiresandSaleRate. Those nine are the complete non-money set; the other 32 getTSM_API.FormatMoneyStringand everything else is handed back raw withisMoneynil. 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 ownprivate.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…|twhen the user has icons enabled and plain names when they do not, read from its ownSmexyMatsDB.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'sOnTooltipSetItemandOnTooltipSetSpell, plusItemRefTooltip(Source/Handlers/TooltipHandler.lua:165,176,187), so a consumer usingGameTooltipwithSetItemByID/SetSpellByIDgets it automatically in its own styling — and theOnTooltipSetSpellhook 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 islocal addonName, rm = ...with zero_Gwrites 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, sinceGetItemInforeturnssellPricenatively.
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.
ItemEffectalone does not answer it — most scrolls teach via a generic intermediary "Learning" spell, so the missing hop isSpellEffect[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.Descriptionis the wrong field for a scroll'sUse:line — populated for 60 of 1,073 real Vanilla scrolls, against the teaching spell'sSpell.Description_langat 1,022.- TBC has no per-recipe teaching spell at all, so no
Use:sentence exists to derive there:ItemEffectcarries the generic spell 483 "Learning" (empty description) plus the craft spell. requiredSkillwas wrong and is now dropped, not fixed. It readSkillLineAbility.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
AttachExternalRecipeInfoas moved and it had not been — no such function, noAllTheThingsreference, noOptionalDepsanywhere 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.tocis nowItemDB_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 Docsships four flavour trees, and Blizzard's own addons use_TBC.tocin all of them — 11-12 of them per Classic tree — with not a single_BCC.tocanywhere in any tree._BCCis an addon-community convention (from "Burning Crusade Classic") that the client also accepts, which is exactly why it spreads. Renamed withgit mvso history follows;Tests/libitemdb_spec.lua,Scoring/TBC.lua,CLAUDE.mdand the replication script's comment moved with it. References inside released CHANGELOG entries,docs/TBC-Parity.mdanddocs/DEPENDENCY_CONTRACTS.mdwere deliberately left alone — they are historical or append-only, and record what the file was called at the time.CLAUDE.mdalso 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 loadsLibStub→ library →Integrations.luain that order in every flavour, becauseIntegrations.luaresolves the library at file scope and returns early if it is not registered yet, which would silently take every bridge with it; exactly oneScoring/file per flavour and only where rules exist; the four bridged addons declaredOptionalDepsand never as hard dependencies; Interface numbers per flavour;Notes/Author/Category/project-id identical across manifests;ItemDB-v0.7.1never hardcoded; and no empty directive.Writing it caught a real structural fact I had wrong twice:
ItemDB.lualoads before the data files, not last, and that is correct — the bootstrap only wires aPLAYER_LOGINhandler and never reads the database at load. The invariant that matters is that the core is a contiguous prefix ahead of allData\files, since every data file calls into the library as it loads. Wrath and Cata ship no data at all and are now flaggedplaceholderexplicitly, 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.htmllisted 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 contradictedREADME.md, which had them right. Verified before syncing rather than trusting either doc: Leatrix Plus doesGameTooltip: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.mdgained 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## OptionalDepsin every TOC, present only so the client loads them first, never as a requirement.ItemDB.luahad never been loaded offline — 0 of 13 lines.Tests/itemdb_spec.luais 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 throughC_AddOns.GetAddOnMetadatawith the bare global staged absent (verified in the Classic Era tree — 0 bare call sites against 5 namespaced, declared inAddOnsDocumentation.lua), so reaching for the deprecated name fails the suite rather than passing quietly; metadata is looked up under the folder nameItemDBwhile the reported identity is deliberatelyLibItemDB, which is the pair most likely to be "corrected" into a bug later; the"dev"fallback for an unpackaged working copy whereItemDB-v0.7.1was never substituted;UnregisterAllEventsbeing the only thing that stops a second run; the silent flag on theLibStublookup; 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:
strsplitdropped a trailing empty field. Empty middle fields were already correct, which is why it survived inspection. In a packedfield<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/GetItemSubClassInfodid 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
GetItemTypeagainst 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

