ProfessionDB-v1.9.1
What's new
Changelog
[v1.9.1] (2026-10-07) - Retail recipes carry the item they craft, Forever gains 12 trainer skill levels
Library MINOR unchanged (13); no API change. Data/Retail/_core WAS
regenerated (ten profession files and RecipeItems.lua), and so was
Data/Forever/_core (trainer ranks, below). Every other tree was rebuilt and
came out byte-identical.
Data
- Fixed: 2,447 Retail recipes shipped with no
craftedItemId. The builder read a craft's product only from SpellEffectEffect 24(CREATE_ITEM). Dragonflight-onward crafts useEffect 288(CRAFT_ITEM) instead: theirEffectItemTypeis 0 andEffectMiscValue_0points at aCraftingDatarow, whoseCraftedItemIDis the product. Example: spell 1229652, Blood Knight's Mercy, goes through CraftingData 2741 to item 237848. Before this fix the recipe carried only its plans (itemId267281), so TOGProfessionMaster drew the row white instead of the item's quality (inbox thread 3ff92175). - New
pdb_common.craft_item_by_spelldoes the 288 → CraftingData hop, used by bothbuild_authoritative_data.pyandbuild-recipe-items.py. When a CraftingData row hasCraftedItemID0, it takes the base-tier item fromCraftingDataItemQuality(the lowest row ID). An Effect 24 row still wins. The two tables are fetched only for a build that has Effect 288 rows, and of the pinned builds only Retail's has any. It raises an error rather than emitting nothing if every 288 spell fails to resolve. Tests/shippeddata_spec.luapins Blood Knight's Mercy against the shipped Retail file.- Forever: 12 more trainer
requiredSkillvalues from a live capture, taken from the operator's 2026-10-07 TOGTools Profession Capture (Forever builds up to 1.60.1.70245).tools/forever_trainer_ranks.jsonwent from 30 to 42 spells, mostly Mining smelts (Smelt Tin 3304 at 65 through Smelt Thorium 16153 at 250), andData/Forever/_corewas regenerated. These are Forever's own ranks, so they replace the borrowed Vanilla value for those spells. The capture's recipe-source walk still answered 0 of 2,511, so Forever still has no unobtainable-recipe exclusions (TOGTools thread c934e6aa). tools/profcapture.pyreports each profession-window read'slibProfessionID/libRecipes. TOGTools v0.10.2 records these when it asks this library for the open profession's unlearned recipes.libRecipes = 0is printed as an EMPTY READ, so a window that got no recipes from the library is not mistaken for the client withholding source text. The script's self-test covers it.
[v1.9.0] (2026-10-04) - Retail support, Forever borrows what it lacks and flags it, Vanilla trainer recipes get their skill, content phases stop leaking out of TBC
Library MINOR 12 -> 13. Data/ WAS regenerated: a new Data/Retail tree,
and every _core tree except TBC (phase lines, plus trainer requiredSkill in
Vanilla and Forever, below).
Library
GetGameVersionanswers"Retail"for Interface 100000 and up (live 12.1.0 is 120100). 60000-99999 stays"Unknown". Operator, 2026-10-04: "we also need to sensure PDB supports all WoW versions, classic, anniversary, MoP, Retail and Forever."- New
skillLinecore field andGetSkillLine(profId, recipeId), Retail only. Retail runs a separate skill line per expansion under each profession (Khaz Algar Blacksmithing 2872 under 164), so a recipe'srequiredSkillanddifficultycannot be read without it. Absent on every Classic flavour. - New borrowed-value flags:
LoadBorrowed(field, fromGame, ids),IsBorrowed(spellID, field),GetBorrowed(spellID). A flavour with no source of its own for a value ships another flavour's value for the same spell and says so, so a consumer can mark it unconfirmed. Fields:requiredSkill,sources,hidden. Operator, 2026-10-04: "i'd rather have a complete data source for most of the functionality and just flag the data we don't have until we can get it". - Fixed:
Searchresults droppedskillLine,requiredSpec,teachesandphase(peer review 4e462d75 F4). Its hit was a hand-written field list; it is now built fromCORE_FIELDS, the listLoadCoreandGetalready use, so a new core field cannot be left out again. Its comment now says hits share the database's tables and are read-only. - Specs: 9 new (flavour detection for Retail and the 60000-99999 gap, the
borrowed flags,
GetSkillLine,Searchcarrying every core field). Library coverage 417/417 (430/430 withProfessionDB.lua).
Data
- New
Data/Retailtree andProfessionDB_Mainline.tocfrom the live client's DBC, wago build 12.1.0.69933: 10,669 recipes across 11 professions (Retail has no First Aid), 12 locales, RecipeItems (5,774 of 11,769 recipes have a teaching item) and AcquireMethods (305 non-zero). No trainerrequiredSkill, Sources or HiddenRecipes: no emulator, AllTheThings install or donor models Retail, so those are absent (unknown). Ace3 and VersionCheck-1.0 are installed on the Retail client, and VersionCheck's own TOC lists 120100. - Retail reagents come from the crafting-slot tables. Modern recipes keep
only a recraft cost in
SpellReagents; the real list isModifiedCraftingSpellSlot->ModifiedCraftingReagentSlot->MCRSlotXMCRCategory->CraftingReagentQuality(build_authoritative_data.mcr_reagents_by_spell). A slot is required whenReagentTypeis 1 orFlagshas bit 4; each ships its base-quality item. Checked against Wowhead for two recipes: Algari Competitor's Plate Breastplate (438914: Core Alloy x2, Ironclaw Alloy x6, Competitor's Heraldry x5) and Wild Experimentation (427174: Coreway Catalyst x2). Not verified across every slot. - The Classic hand-set
requiredSkilloverrides no longer reach Retail. They are Classic-scale numbers (a TBC flask at 375), and Retail rescales each expansion line (Outland Alchemy runs 1-75 there). - Fixed:
phaseshipped on every tree, not just TBC. The ATT phase map is TBC Anniversary's content phases, and_load_phase_map's comment said only TBC gets one, butclean_recipesapplied it to every build: Vanilla carried 15 phase tags, Wrath 209, and Cata, Mists and Forever some too, so a consumer filtering by content phase could hide those recipes on the wrong client. Now only the TBC tree takes it (PHASE_GAMES). Vanilla, Wrath, Cata, Mists and Forever_corewere regenerated one at a time; each diff is phase lines only (git diff -I phaseempty), and TBC regenerated byte-identical. A new spec fails if any non-TBC tree ships a phase. - Forever borrows Vanilla's values where it has none, and flags them.
pdb_common.BRANCH_DONOR = {Forever: Vanilla}. The core builder falls back to Vanilla's trainerrequiredSkill(after Forever's own manual, scroll and captured-trainer tiers) and emitsLoadBorrowed("requiredSkill", "Vanilla", ...). With the learn-spell fix below, Vanilla's trainer source covers 473 of the shared spells. A donor rank is not borrowed when this build's own orange tier is above 1 and higher than it (peer review 9d73103a F1): Forever's Copper Chain Pants is orange at 40 while Vanilla's trainer says 1, so the record shipsrequiredSkillabsent and stays anchored by Forever's own tiers. This also drops a few genuine values (one game's MinSkillLineRank can sit above its trainer rank), which leaves them unknown rather than contradicted. A borrowedrequiredSkillnever re-derives the difficulty tiers (peer review 4e462d75 F2): it is Vanilla's number against Forever's own trivial ranks, and anchoring Forever's Runed Copper Bracers{1,45,90,115}on Vanilla's 90 gave{90,96,102,115}, a curve neither game has. So for consumers the unanchored-tiers rule is now "requiredSkillabsent or borrowed, anddifficulty[1] == 1";shippeddata_specasserts it that way.build_authoritative_sources.pynow writes ForeverSources.lua/SourceNames.luafor the spells Forever shares with Vanilla, andbuild-hidden-recipes.py Foreverwrites Vanilla's never-implemented list kept to Forever's spells, each flagged. Generated: ForeverSourcescovers 1,170 of 1,551 recipes andHiddenRecipesflags 23; the other flavours' Sources/SourceNames regenerated byte-identical. The Forever TOC now listsHiddenRecipes.lua,Sources.luaandSourceNames.lua, and a newhiddenrecipes_specblock checks Forever hides exactly the Vanilla-hidden spells it ships, each flagged borrowed. - The 2026-10-04 Forever walk's trainer ranks are merged
(
tools/forever_trainer_ranks.json, 30 spells) and Forever's_corerebuilt. They take priority over the borrowed values and often disagree with them: Copper Chain Belt 30 (Vanilla 35), spell 3116 40 (Vanilla 65), Copper Chain Pants 15 (Vanilla 1). extract_emulator_trainers._prepare_trinity_cataimportedpy7zrbefore checking the tables were already extracted, so the sources build failed on a machine without it even with every table cached. The import now sits after that check.- Fixed: Vanilla trainer-taught recipes shipped with no
requiredSkill. CMaNGOS Classic'snpc_trainerlists the trainer's LEARN spell, not the craft (blacksmith 1241 teaches 2743, whoseSpellEffectEffect=36 row grants Copper Chain Pants 2662), so the 472 trainer ranks it yields were keyed by spells no recipe carries. The builder now follows that hop (resolve_learn_spell_ranks, sharinglearns_from_spell_effectswith the recipe-scroll chain): 473 Vanilla crafts gainedrequiredSkill(Copper Chain Pants 1, Copper Chain Belt 35), and recipes whose orange tier was the DBC placeholder 1 now anchor on it (Runed Copper Bracers{90,115,127,140}). The donor is resolved through the donor's ownSpellEffect: Forever's DBC lacks most of Vanilla's learn spells, and through it the hop found 73. TBC, Wrath, Cata and Mists regenerated unchanged. ProfessionDB_Mainline.tocdeclares 120005, 120007 and 120100, matching VersionCheck-1.0's list (peer review 4e462d75 F5), so a 12.0.5 or 12.0.7 client does not flag the library out of date. Not checked whether a Retail client loads a TOC above its own interface.ProfessionDB_Camelot.toc's comment no longer says nothing is borrowed from Classic (peer review 4e462d75 F6).MANUAL_EXCLUDED_SPELLSon Retail checked, no change (self-audit 4e462d75 F1). It drops 818 Basic Campfire, 12900 Mobile Alarm and 19106 Onyxia Scale Breastplate from Retail, and Wowhead (Retail) lists 12900 and 19106 as "not in game".clean_recipesnow logs the excluded ids, not just a count.
Tools and dev sync
wow-version-replication.ps1now also copies into_retail_.- v1.4.1 and older moved to a new
CHANGELOG_ARCHIVE.md, whole and unedited, with a pointer at the bottom of this file: the changelog had reached 115,981 characters against the 120,000 ceiling for a GitHub release body. .pkgmetacomments removed (operator rule: no comments in the file the packager parses). Theignore:entries are unchanged.wow-version-replication.ps1now copies into_classic_beta_, the WoW Forever install (Interface 16001,ProfessionDB_Camelot.toc+Data\Forever). Before this, the watcher never copied the Forever tree into the Forever client, so it could not be tested there. Other fleet replicators (ItemDB, QuestDB, Dibs) already target this folder.wow-version-replication.ps1Sync-Fileno longer deletes replicas of a file the source still has (peer review thread a7c434f3, from TOGProfessionMaster). ADeletedfor a path thatTest-Pathfinds again is treated asChanged. Our watcher polls and already checks before raisingDeleted, so the exposure was an editor's save-by-replace landing between the two checks; TOGProfessionMaster's event-driven copy lost a live file from every replica this way.- WoW Forever gets a trainer tier from real Forever trainers. The builder
gave Forever no trainer
requiredSkillat all, because no emulator models it.tools/build_authoritative_data.pynow readstools/forever_trainer_ranks.jsonas Forever's trainer tier, in the same priority slot the emulator SQL fills for Classic (manual > scroll > trainer). The file is generated, never hand-edited:tools/profcapture.py --merge-trainer-ranksfolds a TOGTools Profession Capture into it. Each walk covers only the trainers its walker reached, so the file accumulates, and on any disagreement the lowest rank wins (the emulator tier's rule for duplicate rows). The path and loader live once inpdb_common(capture_trainer_ranks_path,load_capture_trainer_ranks). The first walk (2026-10-04) holds 27 ranks from three apprentice trainers; merging it and regeneratingData/Forever/_corewaits on approval of thepdb-profcapture-mergedesk tool. profcapture.pyno longer calls a silent walk a never-obtainable signal when it is not one. The first Forever walk got source text for 0 of 2,511 recipes, including recipes the same capture saw a trainer teach. The report now says such a walk carries no signal. It flags the silence as evidence only when the client answered for some recipes and no silent recipe is trainer-taught. Raised with TOGTools (contract c934e6aa). Self-test is 27/27 and is now a declared desk suite (profcapture selftest).Tests/wowapipin movedaf2ed57->496da46(harness thread bfadeb8c: Mainline is now Interface 120100). No Adoption-log entry in that range needs an addon-side change, and no spec asserts 110200; the whole suite passes 191/191 at the new pin. Retail = 120100 was also added to this repo's ownTests/env_pdb.luabuildstable.Tests/wowapipin moved27e8850->af2ed57(Writ peer-review thread e3031c41: the old pin predated the harness'sBASELINE.tsv, so writ's spec-shape lint reported 25 findings inside the harness). No Adoption-log entry in that range needed an addon-side change; whole suite 184 passed, 0 failed, and the spec-shape check is now clean.- TOC brand-colour request (DeltaSync thread adfb0ff7): checked, nothing to
change -- all six TOCs already carry
|cffFF8000Lib: ProfessionDB|rwith a closed grey version tag and## Category: Library.
[v1.8.0] (2026-09-25) - WoW Forever gets its own data tree, top-ups merge, peer review moves to the writ inbox, and every Classic tree is re-stamped
Library MINOR 11 -> 12. Three public behaviours changed, so a same-MINOR copy
must not be allowed to shadow this one. Data/ WAS regenerated: every
Classic _core tree one flavour at a time (Vanilla, TBC, Wrath, Cata, Mists),
each diffed before the next, plus a new Data/Forever tree.
Data
- Classic
_coretrees regenerated. Outside 12 Alchemy recipes the diff is header-only: each file now creditstools/build_authoritative_data.pyand carries a-- build <id>stamp. Every per-file recipe count is unchanged. - The 12 TBC transmutes/flasks (28580-28591) now require 375, not 385/390, in TBC, Wrath, Cata and Mists. Their tiers follow: TBC transmutes 28580-28585 375/385/392/400 (was 385/388/392/400), TBC flasks 28586-28591 375/390/397/405 (was 390/393/397/405); Wrath/Cata/Mists 375/376/377/380 (was a flat 385/385/385/385, the TBC-only override clamped above those trees' own TrivialHigh of 380). Derived by the existing pattern-recipe tier rule.
- Vanilla and TBC
AcquireMethods.luaregenerated at the current pins (peer review finding 12). Only the stamp line changed: the data had been right and the stamps stale. - New
Data/Forevertree from Forever's own DBC (1.60.1.69977): 2,511 recipes across 10 professions, 12 locales, RecipeItems (1,859 of 2,664 recipes have a teaching item) and AcquireMethods. No HiddenRecipes, Sources or trainer-derivedrequiredSkill: none of those sources carry Forever data. - MoP pin bumped 5.5.3.67509 -> 5.5.4.69934 (
tools/pdb_common.py), the first 5.5.4 buildcheck-build-pins.py --locales MoPreports complete for every locale.Data/Mistsregenerated through all three builders (core + names, RecipeItems, AcquireMethods), each diffed: stamp lines only. Recipe counts (5,302 core, every locale), teaching items (3,026 of 5,456) and acquisition flags (62) are unchanged;RecipeScrollPrefixes.luabyte-identical. - Vanilla pin bumped 1.15.9.69109 -> 1.15.9.69722, alone and after MoP,
once
check-build-pins.py --locales Vanillareported it complete for every locale. Same three builders, same result: stamp lines only. 1,565 recipes (core and every locale), 1,074 of 1,645 with a teaching item, 28 acquisition flags, prefixes byte-identical. - TBC pin bumped 2.5.6.69110 -> 2.5.6.69795, alone and after Vanilla
validated, once
check-build-pins.py --locales TBCreported it complete for every locale. Stamp lines only: 2,170 recipes (core and every locale), 1,453 of 2,267 with a teaching item, 35 acquisition flags, prefixes byte-identical. Wrath and Cata were reported current by the 2026-09-23 pin check and were not re-swept here.
Tools
- New
tools/profcapture.py: reads a TOGTools Profession Capture and reports it against a shipped tree (default Forever). Forever has no emulator or AllTheThings data, so which recipes a player can obtain, and what trainers ask for them, can only come from a live client. At the operator's direction (2026-09-24) that capture is TOGTools' job (contracte2868011, TOGTools v0.9.0, schema 1); a walker visits trainers and recipe vendors and the SavedVariables come back here. Per shipped recipe the report lists trainer / vendor / drop / known evidence, recipes with none, spells the capture saw that the tree lacks, and trainer skill ranks that disagree withrequiredSkill. It writes nothing underData/. It carries its own strict Lua-literal reader, becauseport_pm_data.py's regex comment strip is unsafe on SavedVariables full of names. It also reads TOGTools' NPC-free recipe-source walk (profCapture.walk, Forever/Retail only), which asks the client for the source text the Professions window shows under an unlearned recipe. That text is client-built and localized, so it is split intoLabel: valuelines with colour codes removed, and the labels are reported verbatim instead of being given meanings here. A walked, unlearned recipe with no source text at all is reported separately as the strongest never-obtainable signal.--selftestpasses 19 of 19. Its readers agree withcore_recipe_idson Forever (2,510 recipes, 1,859 teaching items) and Vanilla. Not yet run on a real capture: none exists yet. - Tested and ruled out: TOGTools' Item DB walk as evidence of obtainability. In both Era walks, the teaching items of all 27 AllTheThings never-implemented Vanilla recipes resolve on the server exactly as real ones do.
Changed (library)
LoadCore/LoadNamesMERGE instead of replace. Only the fields a call supplies are written, driven by oneCORE_FIELDSlist thatLoadRecipesalso loops. The header always promised that other addons could "contribute / top-up" recipes; the old code blanked every field a top-up omitted and left a present-but-hollow recipe. Peer review finding 13, plus a follow-up that removed a second hand-written copy of the field list.GetGameVersioncan answer"Forever"or"Unknown". Interface 16001 exactly is"Forever", tested before the Vanilla branch; any other 16000-19999 client, and anything at 60000+ (Mists is now bounded), is"Unknown"and loads no data (IsReady()false) instead of Era's or Mists' recipes as its own. Peer review finding 16 and thread 3900ffe9.LoadSourcescounts true totals across a merge and copies instead of adopting. An untruncated contribution was dropped from.total(3 + {2, n=100} reported 100, not 103), and the first caller's array was appended into by later merges. Peer review findings 14 and 15.
New
ProfessionDB_Camelot.toc(WoW Forever, Interface 16001) loads the library and theData/Forevertree, so an addon that hard-depends on ProfessionDB is no longer refused on Forever (Dibs was) and gets real data.- A spec compiles every data file every TOC lists. Only Vanilla/TBC files
were ever executed by the suite; the first Forever build shipped a koKR
Enchanting file with a raw newline inside a name string (spell 8694), which
this caught.
lua_valuenow escapes\nand\r. Every Classic file compiled, so no released data was affected.
Changed (pipeline, tools/)
- WoW Forever is a branch flavour (
pdb_common.BRANCH_BUILDS, pin1.60.1.69977, wago branchwow_classic_beta), kept off the Classic expansion axis: no emulator trainer tier, no emulator Sources, not part ofmerge_expansions. Those ship as visible gaps rather than borrowed Classic data. - Both DB2 schemas are read.
pdb_common.item_effect_pairsjoins Forever'sItemXItemEffectwhere Classic hasItemEffect.ParentItemID, andspell_effect_base_dietranslates Forever'sEffectBasePointsF. On Vanilla's cache both return exactly what the old reads did (16,944 pairs; 0 of 40,250 SpellEffect rows differ). - 12 TBC Alchemy overrides 385/390 -> 375 (28580-28591). The old values
were the DBC trivial column, above TBC's 375 cap; Wrath, Cata and Mists all
carry 375 in that column, and warcraft.wiki.gg reads 375 for all twelve.
manual_skill_overrides.jsonnow documents every source label in use. SHORT_ROW_RATIO0.80 -> 0.95, measured. 201 legitimate same-expansion pairs bottom out at 99.3%; known short exports sit at 19-56%.- The builder now stamps every
_corefile with its build, andcheck-build-pins.py --stampschecks every stamped file, not justRecipeItems.lua(peer review findings 11 and 12). It never prints an all-clear while files went unchecked, and a branch flavour with no tree is reported rather than failing the run (peer review thread 3de6a4bd). --builds <one flavour>no longer overwritesrecipe_items_map.json, which the sources builder reads for every expansion; it is written only when the whole Classic axis was built.build-recipe-items.py's "gathering lines have no scrolls" check is Classic-only. Forever teaches housing blueprints on Herbalism and Skinning ("Blueprint: Greenhouse", "Blueprint: Field Guide"), so the join is right.
Changed (process)
- Peer review, harness contracts and dependency contracts moved to the writ
inbox.
docs/AUDIT.md,Tests/HARNESS_CONTRACT.mdanddocs/DEPENDENCY_CONTRACTS.mdwere imported and removed; git history keeps every committed line. The audit board's rounds 24-25 were never committed and survive as inbox threads only. - Suite 169 -> 184 specs, 0 failed. The unanchored-tiers rule and the
compile-every-listed-file spec both walk six trees now,
ProfessionDB_Camelot.tocincluded.
Documentation
- README and CurseForge page brought up to the v1.8.0 tree. Forever added
wherever the flavours are listed, with what it lacks (sources, never-implemented
list, trainer
requiredSkill) stated beside it; the build-pin table and the CurseForge build list now read Vanilla1.15.9.69722, TBC2.5.6.69795, Mists5.5.4.69934; the "Mists is deliberately behind" paragraph is rewritten as history; the build-stamp paragraph no longer claims onlyRecipeItems.luais stamped. Measured off the Forever tree for the unanchored-tiers table: 614 unanchored, 14 absent-but-anchored, 26 withrequiredSkillexactly 1 (totals now 61 and 218). Forever scroll coverage 1,859 / 2,664 (69.8%), 3 rank books, from itsRecipeItems.luaheader. - CurseForge v1.8.0 notes grouped under New / Changed / Fixed, with the
fix:prefixes dropped, and the Vanilla/TBC/Mists build moves added.
[v1.7.0] (2026-08-20) - Trainer requirements are scoped per expansion, scroll prefixes ship their sample count, and Vanilla stops borrowing a later expansion's numbers
This entry started as tooling-only and did not stay that way. An earlier
draft of this paragraph said "nothing in this entry changes a single byte that
reaches a player", which was true when it held only the harness pin and the
replication script. It is now false, and the correction is the headline:
Data/ is rewritten across all five expansions and the library gains a public
API return.
MINOR, not PATCH, and the deciding facts rather than the feeling:
GetRecipeScrollPrefix returns a second value and LibProfessionDB-1.0 is
at MINOR 11, which this project bumps only when the public API changes; a new
loader, LoadRecipeScrollPrefixSamples, ships new data in all 60 locale
files (12 locales x 5 games); and a new TBC trainer source was added to the
pipeline. All of it is backwards-compatible -- GetRecipeScrollPrefix's first
return is unchanged, so no existing call site moves -- but additive is not
corrective.
Read Changed (shipped data) before upgrading if you consume this library.
Vanilla requiredSkill coverage drops about 25% on purpose, and
docs/DEPENDENCY_CONTRACTS.md section 6 carries the consumer rule that goes
with it.
Fixed
wow-version-replication.ps1was replicatingTests/,docs/andtools/into the_classic_and_anniversary_installs on every sync, while reporting those exclusions as loaded.Convert-GlobToRegexcompiled a folder entry to^Tests$, which matches a FILE literally namedTestsand nothing else. It could never have worked:$isDirwas set from a trailing backslash, and a trailing slash is exactly what the BigWigs packager forbids (release.sh'sparse_ignore()trims a trailing/*, not a bare/, soTests/would compile toTests//*and match nothing), so a correct.pkgmetacan never set that flag. Fixed by promoting$isDirwhen a wildcard-free entry resolves to a real directory under$Source, the idiom already inAceCommQueue-1.0:125-130. The wildcard exclusion is load-bearing:Test-Pathwould glob a pattern and could promote an unrelated directory.This stopped being cosmetic when
Tests/wowapigrewmcp/(a Python tree) andconformance/(a loadable addon with its own.toc), because a sync then installed a second addon into the other flavour installs. Raised asdocs/AUDIT.mdfinding 2 and answered there. The.pkgmetaitself was already correct, so the released zip was never affected.The same script replicated every dotfile it had not been told about by name. The always-skip list was five explicit patterns covering
.git,.gitignore,.gitattributesand.gitmodules-- i.e. exactly the dotfiles somebody had enumerated.-DryRunshowed.busted,.luacheckrc,.luarc.json,.markdownlint.json,.markdownlintignore,.github\workflows\release.ymland.vscode\tasks.jsonall copying. This one cannot be delegated to.pkgmeta: the packager prunes dotfiles unconditionally incopy_directory_tree()(-name ".*" -prune), so a dot entry in anignore:list is a no-op there and nobody writes one -- which makes this script the only place the rule can live. Replaced the enumeration with the general component rule'(^|\\)\.'. It is load-bearing rather than tidy:.gitin these repos is a one-line gitdir pointer file (the real git dir lives outside the WoW tree), so replicating it aimed the copy at the wrong repository.Found by the
-DryRunrun that was verifying the fix above, not by reading. It is the third instance of one species in this repo, after finding 1 (a guard that could not fail the way the incident failed) and finding 2 (an exclusion that could not match the thing it named): a mechanism that reports success while doing nothing, made invisible by a startup banner that prints a count of rules parsed and reads as coverage.
Changed
Tests/wowapipin moved59c4280->27e8850, 86 commits. Suite 103 passed, 0 failed, 0 pending before and after;verify-addons150/150 files,verify-widgets27/27,verify-libs8/8 withLibProfessionDB-1.0reporting 51 methods. Nothing in those 86 commits reached this library, and that is recorded rather than left as silence: the Adoption log carries several entries warning a consumer's suite may go red (UnitName/UnitClassanswering per token,SetCVarfiringCVAR_UPDATE,ColorPickerFramecopying its info,GetTexCoordreturning eight values,UIDropDownMenu_Initializerunning its initialiser,SettingscategoryIDbecoming a number, the drag/resize model), and none of them touched us becauseLibProfessionDB-1.0readsGetBuildInfoandGetLocaleat load and no client API after. A green suite here really is a no-op.
Documentation
AttachExternalRecipeInfowas documented inREADME.mdand on the CurseForge page for three releases and HAS NEVER EXISTED IN THIS LIBRARY. Both references are removed. A consumer following the documentation getsattempt to call a nil value (method 'AttachExternalRecipeInfo'), which is the worst grade of documentation defect -- not vague, not out of date, just describing a method that is not there.How it happened, because the mechanism is reusable: when the recipe-scroll API moved here from
LibItemDB-1.0in v1.5.0, the delivery note indocs/DEPENDENCY_CONTRACTS.mdlisted this method as having "came with the rest, on your reasoning -- it is keyed by spell and draws a recipe tooltip, and reads no item data at all." The reasoning was sound and the method was never written. The README was updated from the migration PLAN rather than from the migrated CODE, and every later edit inherited the row without re-checking it. Verified now withgit log -S AttachExternalRecipeInfo -- LibProfessionDB-1.0.lua, which returns no commit at all.ItemDBandTOGProfessionMasterare told, in an appended note indocs/DEPENDENCY_CONTRACTS.mdunder the original delivery. If either is feature-detecting withif DB.AttachExternalRecipeInfo thenit has been silently taking the false branch since v1.5.0 and needs nothing; a direct call has been erroring.LoadRecipeScrollPrefixSamplesis documented. v1.7.0 added a third loader reading<locale>/RecipeScrollPrefixes.lua, and the README's ingestion table listed only two. The same section warns that "a parse anchored to the file rather than the loader will sweep unrelated tables together -- the mistake two independent scripts have already made here", and the count in that warning was itself now wrong. Both the table and the count are corrected.Four stale figures corrected, each re-measured against the tree rather than carried forward. These are the sort of number that goes wrong silently, because nothing recomputes a sentence:
Claim Was Is Where it was checked Vanilla scroll coverage 1,073 (65.2%) 1,074 (65.3%) Data/Vanilla/_core/RecipeItems.lua:8Vanilla synthetic descriptors 572 571 same header Vanilla never-implemented, shipped 21 25 Data/Vanilla/_core/HiddenRecipes.lua:8Data/*/itIT/files63 62 counted off disk The first two moved in this release -- the
is_obsoletefix gave one more recipe a real teaching item, so it left the synthetic set. The third has been wrong since v1.6.0, when the extractor fix raised it from 21 to 25 and the README was not followed through. The generators write their own counts into each data file's header, which is why three of the four were checkable in one read: prefer the header to the prose.The esES/esMX scroll-prefix note was internally contradictory and is now measured. It read "esES ships only 2 of 9 ... esMX has all 8" -- nine and eight in one sentence. Measured on Vanilla: enUS derives 9, esMX 8, esES 2. esMX is missing Mining (
186), which is the same profession whose enUS prefix rests on a single scroll -- so the locale gap and the sample-count feature are the same underlying thinness, and the README now says so.The CurseForge page no longer lists Italian as a supported language. It sat in a flat list of locales "-- every official WoW locale", which reads as a promise to an Italian player. Italian is a Retail locale, cannot load on any flavour this library ships, and the text inside those files is English. It now has its own entry saying exactly that, rather than being quietly counted.
The CurseForge HTML is now PARSED rather than read. Every review of that file to date checked it by eye or by grep, and a pre-release review nearly filed a false finding when its own output escaping manufactured a malformed
<\code>tag that was not in the file. It parses clean: every tag balanced, every entity defined.docs/AUDIT.md: finding 2 answered FIXED in place, with the dry-run evidence both before and after. Its Status row could not be updated -- the enforced append-only law refuses both a rewrite and the documented strike-in-place shape when the target is a table cell -- so state is carried in an appended## Status updatesection instead, per the harness's own 2026-08-14 instruction.Tests/HARNESS_CONTRACT.md: records the new pin (thePin:line in the header is append-only and stale), and opens one contract on the contradiction above, where two protocol documents tell ~20 consumers to do something the law refuses.Both files gained a
charset-okdeclaration. They are developer threads that ship no text to the client, they are append-only in both directions, and each side quotes the other verbatim -- so neither side may normalise the other's punctuation, and without the declaration the charset law blocks every future response that has to quote an existing line.Get-PkgmetaIgnoressilently repaired input the packager breaks on, so the dry run could not fail on the one.pkgmetadefect that ships an empty zip.wow-version-replication.ps1:140-166. The parser stripped trailing comments before reading a list item; the BigWigs packager does not. On- "*.ps1" # glob,release.sh'syaml_listitem()removes the opening quote and finds no closing one, the stray quote breaks theevalincopy_directory_tree, the destination lands empty, every file is copied nowhere, and the packager still exits 0 and uploads a ~108-byte archive. Because-DryRunis now the acceptance test for the two exclusion fixes above, the one check that most needed to fail on that file could not: a green dry run would have been produced by the exact.pkgmetathat ships an empty release.Now it warns instead of repairing -- the script's job is to agree with the packager, not to be kinder than it -- and the inline strip is anchored to a whitespace-preceded
#, since#is a legal filename character.Closing it introduced a regression in the same function, which is also fixed. Anchoring the strip meant a column-zero whole-line comment inside the
ignore:block no longer blanked; it survived as# ...and the next test read it as a new top-level YAML key, terminating the block and discarding every entry after it. So a fix aimed at one report-success-while-doing-nothing mechanism briefly installed another one, three lines away. Comment lines are now skipped outright before anything else looks at them. Our.pkgmetakeeps its comments aboveignore:and would not have hit it.Verified: the parser still loads the same eight globs, no warning fires on the real file, and
WOULDforTests|docs|toolsand for any dot component are both still 0. The guard itself is proven at the predicate level over seven cases, including- "weird#name.lua"surviving intact. It was not driven end to end from a genuinely broken.pkgmeta: a repo law refuses to write one, detecting the same empty-zip shape. Raised asdocs/AUDIT.mdfinding 3.
Fixed (data pipeline)
Trainer skill requirements were held in ONE flat map across every emulator source, so whichever source sat last in the list silently rewrote every other expansion's shipped data.
extract_all_skill_ranksintools/extract_emulator_trainers.pyreturned a single{spellId: rank}dict built across all four world DBs, licensed by a comment atbuild_authoritative_data.py:187-189reading "the data is build-agnostic (trainers are server-side, not per-client-build)". That premise is false, and the sources themselves are the disproof:cmangos_classicteaches Smelt Silver at 65 andcmangos_tbcat 75. "Not derived from a client build" does not imply "the same in every expansion". A flat map physically cannot hold two answers, so every build read whichever one was written last -- the cross-expansion merge thatbuild_authoritative_data.py:1528states in as many words does not happen: "Not merged across expansions -- each game folder is that build's point-in-time set." Two comments in one file asserting opposite things about whether expansion matters.A second precedence rule was broken by the same mechanism, and nothing asserted it: the function's own docstring said "SOURCES is ordered oldest -> newest ... so a straight overwrite as we iterate gives the right precedence", and
cmangos_tbchad been appended to the end ofSOURCES. The oldest non-vanilla source was overwriting the two newest. A precedence policy expressed as list order is a policy an append can void.Fixed by keying the map by expansion:
extract_all_skill_ranksnow returns{expansion: {spellId: rank}}, andskill_ranks_for_expansion()flattens it for one target build under one written rule -- a build takes trainer values from its own expansion or any older one, never a newer one; within that set the newest wins.EXPANSION_ORDERis the single place the axis is written down, soSOURCESorder is now decorative and cannot express a precedence.extract_recipes_for_buildresolves itsEXPANSION_BUILDSlabel once at the top (it had been resolving it 200 lines later forBUILD_EXCLUDED_SPELLS) and passes it down. The false comment is replaced by its disproof, and the stale "AzerothCore Wrath + TrinityCore Cata" note in therequiredSkillpriority chain now names all four sources.Duplicate rows for one spell within a single source now take the LOWEST rank and every disagreement is printed, replacing a dict assignment that made the shipped value depend on row order.
requiredSkillanswers "what skill do I need to learn this", so if any trainer teaches it at 310 the answer is 310. There are seven, all incmangos_tbc, and six were unknown: spells 10548 (230/235), 10619 (225/255), 19047 (230/250), 19058 (230/250), 26926 (5/20), 29557 (310/315), 34983 (350/360).This closes an open item that had two wrong hypotheses attached to it. Four
Data/Mists/_core/files -- Blacksmithing, Engineering, Jewelcrafting, Mining -- had been diverging from their committed state at an unchanged DBC pin, and had been blamed first on theis_obsoletefix changing a teaching-item tie-break and then on a stale builder. Both were wrong. It was the flat map, and scoping it reverted all four to byte-identical without anything else being touched. Raised and answered asdocs/AUDIT.mdrounds 7 and 8.The fix above shipped with the flat map still live for one expansion, because two lists had to agree and nothing checked that they did.
skill_ranks_for_expansionfell back to "use every source" for a label it did not recognise -- which is the flat merged map, restored -- andEXPANSION_ORDERended in"mists"whilepdb_common.EXPANSION_BUILDScalls that expansionMoP(only its Data folder is namedMists). So the MoP tree never recognised its own label and took the fallback for the whole of the change that was meant to remove it. A fix for "a rule living in the incidental agreement of two things", carrying a rule living in the incidental agreement of two things.Data consequence: zero, and structurally rather than by luck. With the label corrected the tree regenerates byte-identical. Scoped, MoP takes
vanilla -> tbc -> wrath -> cata, there being no MoP trainer source; unscoped it took those same four plus a"mists"key nothing ever wrote to. The newest expansion is precisely where "everything" and "everything not newer than me" coincide, so the defect could not show itself in the output of the only tree it reached. Measured after the fix rather than predicted before it.Closed three ways, all of which were needed:
EXPANSION_ORDERnow spells the label"mop"under a comment saying so is load-bearing;skill_ranks_for_expansionraises on an unrecognised label instead of merging; andbuild_authoritative_data.pyasserts at import that everyEXPANSION_BUILDSlabel lowercases intoEXPANSION_ORDER. That assertion is what found this -- it failed on its first run with['MoP']. Raised asdocs/AUDIT.mdround 10 finding 2.Five pipeline guards were written in a construct Python is allowed to delete.
python -OandPYTHONOPTIMIZE=1stripassertstatements outright, so each of these stopped existing on an environment variable with no code change: the ascending-major ordering check inextract_emulator_trainers.py, theEXPANSION_ORDERderivation guard inbuild_authoritative_data.py, and the three partition guards inbuild-recipe-items.pythat assert no spell is in both the real and synthetic scroll tables, none is in neither, and no skill-rank book is given a synthetic scroll.The last three are the serious half: they protect shipped data integrity and fail quietly. All five are now
if not <cond>: raise ValueError(...), which no interpreter flag removes. LuaassertinTests/is untouched and correct -- the busted runner has no-Oand Lua'sassertis an ordinary function call.Measured rather than accepted from the language spec: under
-O,assert [5,4,3,2,1] == sorted(...)passes silently; the replacement raises under both plainpythonandpython -Owhen fed a reversedEXPANSION_BUILDS. That red-test also closes a guard that had been reported twice as undriven -- converting it is what produced a failure path worth driving.docs/AUDIT.mdround 14 finding 5.extract_recipes_for_buildno longer defaults an unknown build to its own build-id string. The label lookup ended, build), and a build-id can never be an expansion label -- so any build absent fromEXPANSION_BUILDSwould have carried that string into the trainer lookup and taken an unscoped map for its whole tree, as well as silently missingBUILD_EXCLUDED_SPELLS. There is no correct answer for a build with no pin, so it raises.The trainer-extraction failure message promised a fallback the code refuses. It said "trainer-taught requiredSkill will fall back to SLA heuristic". It does not: the priority chain is
manual or scroll or trainer, so with no trainer data the value isNoneand the field is omitted -- and the chain rejects the SLA fallback precisely because it masks the gap. Post-388 that was actively misleading: an operator expecting heuristic values would get omissions plus placeholder difficulty tiers. It now says what happens, calls the result a bad build that must not ship, and points atdocs/DEPENDENCY_CONTRACTS.md§6.docs/AUDIT.mdround 10 finding 3.The obsolete-item filter matched the ordinary English word "Old", so a real recipe scroll was thrown away and replaced with a fabricated one.
tools/build-recipe-items.pyskipped any item whose name matched(^|\W)(ZZOLD|OLD|TEST|DEPRECATED|\[PH\]|PH\b|UNUSED)before the teaching-item join ran.OLDhad no right anchor, so item 234255 "Recipe: Flask of the Old Gods" was discarded as a Blizzard dev leftover. With no item left teaching craft 1213548, the builder fell through to the synthetic path and shipped a made-up scroll for a recipe whose real scroll exists -- breaking the invariant that a recipe answersGetRecipeItemorGetSyntheticRecipeScrolland never the wrong one.Traced end to end on the cached build
1.15.8.67156rather than inferred:SkillLineAbilityputs craft 1213548 on skill line 171,ItemEffectgives item 234255 the spell 1213550,SpellEffectgives 1213550 an Effect 36 granting 1213548, andItemSparsenames 234255. Every input the join needs was present; the filter ran first.The interesting half is that the correct rule already existed in the same folder.
tools/build_authoritative_data.pyanchored it as\bOLD$-- trailing marker only -- under a comment reading "Conservative on purpose: matches must be word-boundary or unique-bracket forms so the filter never eats a legitimate item." Two implementations of one rule, drifted, one wrong. The bad copy's own comment claimed "Same intent as the obsolete filter in the other build-*.py scripts", which was false and read as a reason not to check.Fixed by removing the duplicate rather than patching it: the predicate is now
is_obsolete_item_name/OBSOLETE_NAME_PATTERNSintools/pdb_common.py, carrying the careful version verbatim, and both builders import it. Verified by running it:"Recipe: Flask of the Old Gods"is no longer obsolete while"Pattern: Double-stitched Leather Gloves OLD","ZZOLD Design: ..."and"59 TEST ..."still are. Measured blast radius on Vanilla is exactly two matching scroll names, one of each kind, so the live cost was one recipe -- but the pattern was a standing trap for any future item named with "Old", "Test" or "Unused" as an ordinary word.Found by ItemDB, who declined to accept "build drift" for a row that looked missing and asked for it to be checked. Answered in full in
docs/DEPENDENCY_CONTRACTS.md.Regenerated, and the whole five-expansion diff is two lines in one file:
+[1213548]=234255 (into LoadRecipeItems) -[1213548]={171,233965} (out of LoadSyntheticRecipes)Vanilla real count 1073 -> 1074, synthetic 572 -> 571, partition assertion still passing at 1645 of 1645. TBC, Wrath, Cata and Mists regenerated byte-identical. That is better evidence than the fix itself: it shows the corrected filter changed exactly the one thing it was meant to.
The stale DBC build pins are bumped, ONE AT A TIME with the diff read between each. Vanilla
1.15.8.67156->1.15.9.69109, TBC2.5.5.67511->2.5.6.69110, MoP5.5.3.67509->5.5.4.69383. Wrath and Cata were already current. Each bump re-fetches that expansion's DBC CSVs and regenerates it through both builders --build_authoritative_data.pyfor the cores and 12 locales of names, thenbuild-recipe-items.py.--builds <label>is safe to use for one expansion at a time, and that was checked rather than assumed: the spec-lock map atbuild_authoritative_data.py:1497iteratesEXPANSION_BUILDSin full regardless of the filter, so restricting the emit does not shrink the union it is built from.CORRECTED BEFORE RELEASE -- the paragraph that stood here was wrong. It presented a table of changed skill values as "the drift between the two builds" and called them Blizzard tuning passes. They are not build drift at all. Every one of those values comes from the TBC emulator trainer source added in this same release (see below); measured afterwards,
cmangos_tbc_npc_trainer_template.sqlcontains all of them andcmangos_classic_*contains none:recipe shipped regenerates in the new TBC trainer source Smelt Silver (2658) 65 75 75 Smelt Bronze (2659) 50 65 65 Smelt Tin (3304) 50 65 65 Engineering (12895) 205 200 200 Jewelcrafting (31048/31049) 305 310 310 The reasoning that produced the wrong answer is worth more than the correction, because it looked like evidence: the changes were argued to be genuine because
requiredSkillanddifficulty[1]moved TOGETHER. That coherence is manufactured -- the builder derives the difficulty tiers fromrequiredSkillatbuild_authoritative_data.py:848-856. Two fields moving together says nothing whatever about where the value came from, and it was used as the main argument.The pin bumps themselves stand; what was wrong was the claim about which changes they caused. Partition assertions unchanged on both (Vanilla 1074+571=1645, TBC 1453+814=2267), suite green throughout, and the whole change set is raised for review in
docs/AUDIT.mdround 7.Vanilla's was a restoration, not a move: its shipped tree had already been generated at
1.15.9.69109and only the pin had been left behind. It also closes the standing "build drift" disagreement with ItemDB, who were on 1.15.9 while we were pinned to 1.15.8.MoP's bump was attempted, FAILED VALIDATION, and is reverted -- and the pipeline now refuses the failure instead of shipping it. With MoP at
5.5.4.69383,Data/Mists/zhCN/RecipeScrollPrefixes.luasilently lost two professions -- Jewelcrafting and Inscription, 10 down to 8. Every count and assertion still passed: partition 3026+2430=5456, 5,302 cores, suite green. The file even wrote "8 professions" in its own header, so it read as a legitimate locale difference between builds.wago's 5.5.4 zhCN and zhTW
ItemSparseexports are incomplete at source, and the items that vote for those prefixes were in the missing 80%. Probed rather than assumed: the response isTransfer-Encoding: chunkedwith no Content-Length, ends cleanly on a complete CSV row, and returns exactly 5,981,882 bytes / 17,021 rows on every fetch -- against 88,671 for zhCN on 5.5.3 and 88,753 for enUS on 5.5.4. Deterministic, so not a timeout and not a cut stream.SkillLineAbilityhas 621 Inscription rows on both builds, so nothing about the game changed, only what arrived.MoP is reverted to
5.5.3.67509and regenerated from its intact caches; zhCN is back to 10 professions.wago_textnow refuses a short export rather than caching it. The check is the row count against the same table and locale on another build of the same expansion -- and same-expansion is load-bearing, since Vanilla'sItemSparseis legitimately ~26% of MoP's and a sweep that ignored it produced 163 suspects of which 162 were noise. Nothing is written on failure, so a re-run retries the fetch rather than reusing a bad file, and a first-ever fetch with no sibling to compare against is allowed through rather than blocked.The Content-Length check added alongside it is nearly useless here and is labelled as such, because the first version of this entry claimed otherwise: wago sends no Content-Length on this endpoint, so it never fires. It is kept for other hosts. The row-count guard is what protects this pipeline.
tools/check-build-pins.py: how far each pinned DBC build has fallen behind its live branch, on demand. Measured 2026-08-20 before the bumps above -- three of five were behind and two current:pinned newest live branch Vanilla 1.15.8.67156 1.15.9.69109 wow_classic_era3 behind TBC 2.5.5.67511 2.5.6.69110 wow_anniversary9 behind Wrath 3.4.5.63697 3.4.5.63697 wow_classiccurrent Cata 4.4.2.60895 4.4.2.60895 wow_classiccurrent MoP 5.5.3.67509 5.5.4.69383 wow_classic14 behind The branch is now pinned per expansion in
pdb_common.WAGO_BRANCHand must never be inferred from the version major. Finding "the newest build for this expansion" by matching on major produced two false positives, one of them badly wrong:wow_classic_titan3.80.2 is Titan Reforged, a different product that answers to "3." and reads as a 14-build-newer Wrath, andwow_classic_era_ptradditionally carries 10.1 retail builds, so "newest matching major" there is not an answer at all. TBC lives onwow_anniversary, not onwow_classic_era; Wrath, Cata and MoP sharewow_classic, which carries 5.5 / 4.4 / 3.4 / 2.5 / 1.13 side by side and so still needs a major filter on top of the branch.The tool exits 1 when any pin is behind, 0 when all are current, and 2 when wago could not be reached -- a checker that could not look must not report a pass. Read a non-zero exit as a question rather than a verdict: bumping a pin regenerates every file for that expansion through both builders across 12 locales and changes what players receive.
The pinned Vanilla wago build is OLDER than the build the shipped data was last generated from, and the regeneration is what surfaced it. The header stamp went backwards --
client build 1.15.9.69109->client build 1.15.8.67156-- so a previous generation used a newer build thanpdb_common.EXPANSION_BUILDSpins, and the pin was never moved. This is exactly the "build drift" ItemDB raised and I twice set aside as not worth chasing; they are on 1.15.9 and we are pinned to 1.15.8. Content cost today is zero -- a full regeneration at 1.15.8 changed one row, so the two builds agree on everything else this builder emits. The pin is deliberately not bumped: doing so regenerates all five expansions against newer DBC for a genuinely large diff, which wants its own pass rather than riding along with a one-row fix. On the repo todo list.
Documented (locales)
itITcannot load on any Classic client, and is now labelled FORWARD-LOOKING instead of being described as correct. Italian is a Retail WoW locale and is not offered on any Classic flavour: the original Mists of Pandaria shipped fully localised in Italian in 2012 and MoP Classic did not, which separates "the content was never localised" from "the Classic pipeline does not ship Italian" -- it is the latter. Every data file opens with aGetLocale()guard and this library ships no Retail flavour, so the 63Data/*/itIT/files can never execute for a player.They are kept deliberately, not by inertia: Blizzard is being actively petitioned for Italian on Classic, the files are already correct and current, and re-adding after a delete costs a full regeneration. The note now carries the drop trigger -- if the package ever needs trimming this is the first thing to go, with the exact steps -- because "forward-looking" is otherwise how dead weight becomes permanent.
Two wrong justifications preceded this and both are recorded in
pdb_common.pyabove the right one, because the failure is more instructive than the answer. The first inferred what the client holds from an absence in a third-party export. The second said it did not matter either way -- sound reasoning that dodged the question instead of answering it, and that is what stopped anyone searching: once a question is moot you no longer need the answer, so you stop looking. The answer was one web search away.docs/AUDIT.mdrounds 17-18.
Changed (build pins)
MoP pin bumped
5.5.3.67509->5.5.4.69032. The builder fetched and cached the new build's tables and emitted a Mists tree identical to the old one. That sentence is phrased around the RUN rather than the OUTPUT on purpose, and the reason is worth keeping: on this tree a zero-diff is unfalsifiable._corefiles carry no build stamp, so a correct rebuild and a silently-skipped rebuild produce byte-identical output and no artefact in the repo distinguishes them. "The files are identical" therefore cannot be wrong in any observable way, while "the builder fetched5.5.4.69032__*and wrote them to the cache" can be -- so that is the claim being made. Raised asdocs/AUDIT.mdround 17.With that caveat stated: no shipped byte changed. Worth saying plainly, because "14 builds behind" sounded like a content gap and was not. The bump is hygiene -- it puts the pin on the era the client is actually in, so the next cross-repo comparison with
LibItemDBstarts from a shared build instead of rediscovering the difference.It was pinned to
69032rather than the newest69383, and that is deliberate. wago serves individual(build, locale)exports independently, and a build being newer says nothing about whether it is fully served:build zhCN zhTW 5.5.4.69383(newest)short, 17,020 recs short, 50,141 5.5.4.69155complete short, 7,360 5.5.4.69078not tested short, 4,370 5.5.4.69032complete complete zhTW is the binding constraint and it is not monotonic -- it is worse on two older builds than on the newest -- so "wait until wago fixes it" was never going to terminate. A pin is only worth what you can generate from it, so the newest FULLY-SERVED build beats the newest build.
This corrects the previous note in
pdb_common.py, which said the exports were "incomplete at source". That claimed the gap was in Blizzard's data; it is not, and one fetch disproved it. The old comment is left standing in the file as the record of what was believed and what overturned it.CORRECTION, and it reverses the two bullets above:
5.5.4.69032IS NOT FULLY SERVED, the bump is REVERTED, and MoP stays on5.5.3.67509. Both bullets are left standing because the reasoning in them is the thing that failed, and it failed in a way worth being able to read back.The regeneration was re-run and
_assert_not_shortrefused the first locale it fetched:ItemSparseat5.5.4.69032fordeDEreturns 26,451 rows against 88,754 on69383-- 30%. Not zhCN. Not zhTW. A locale nobody had looked at.The defect was the method, not the tool. The table above sweeps exactly two locales, on the reasoning that zhTW was the worst case and therefore decisive. That reasoning can prove a build serves zhTW; it cannot prove a build serves deDE, and nothing had asked.
check-build-pins.py --localesalready scans all eleven non-mirrored locales -- the shortcut was taken by hand, outside the tool, because a full sweep is slow. A completeness claim built from a sampled subset is not a completeness claim, and "the binding constraint" was only ever the binding constraint among the locales fetched.What this leaves behind is a MIXED-BUILD TREE, which is why it is a revert rather than a note.
pdb_commonsaid5.5.4.69032whileData/Mists/_core/RecipeItems.luawas stamped5.5.3.67509-- the pin described a build the data had never been generated from. This repo's own record calls that outcome worse than a consistent old pin. Reverting makes the pin true and changes no shipped byte: re-runningbuild-recipe-items.py Mistsat5.5.3.67509completes and emitsRecipeItems.luabyte-identical to what is committed.It also retires the zero-diff caveat above, in the worst way. Round 17 flagged that a zero-diff on this tree is unfalsifiable, because
_corefiles carry no build stamp and a correct rebuild is indistinguishable from a skipped one.RecipeItems.luadoes carry a stamp, and it read5.5.3.67509the whole time -- so the run described as "fetched and cached the new build's tables" cannot have reached the emit, because the deDE fetch fails before it. The stamp was sitting in the repo, unread, saying the bump had not happened.check-build-pins.pynow asks the mixed-build question too, offline, and it runs BEFORE the network call. The check above was done by hand. Doing it by hand is how it went unasked for a day, so it is a tool now:python tools/check-build-pins.py --stamps # offline, no network python tools/check-build-pins.py # both checksIt compares every pin against the
client buildstamp in that expansion'sData/<game>/_core/RecipeItems.luaheader and reportsMIXED BUILDwhen they disagree. The two checks fail in opposite directions, which is why both exist: a stale pin ships consistent data describing an older client, which is a question; a mixed build ships data from a build the pin does not name, which means nothing in the repo describes what players receive.The ordering is load-bearing. The offline half runs first and its verdict is printed even on the
wago unreachableexit-2 path, because a failed bump and a wago outage arrive together far more often than either arrives alone -- that is exactly the situation that produced this defect. A mixed-build result also decides the exit code, so a stale-pin1cannot mask it.RED-TESTED, not assumed. Re-pinning MoP to
5.5.4.69032makes it exit1naming MoP and both builds; reverting makes it exit0. A missing or unreadable stamp is a failure rather than a skip, on the same rule that a checker which could not look must not report a pass.The full eleven-locale sweep then ran, and MoP stays on
5.5.3.67509because NO 5.5.4 build serves every locale. This is the measurement the pin should have rested on in the first place:build short locales count 5.5.4.69383(newest)zhCN, zhTW 2 5.5.4.69155ruRU, zhTW 2 5.5.4.69078deDE, esES, esMX, frFR, itIT, ruRU, zhTW 7 5.5.4.69032deDE, esMX, itIT, koKR 4 69032does serve zhCN and zhTW in full, so the two-locale table further up is accurate. It is simply the one pair of the eleven that made a build with four short locales look like the right answer.Which way to look next, flagged as a hypothesis rather than a finding: the two oldest builds scanned score worst (7 and 4) and the newest scores joint-best (2), so wago's exports appear to fill in over time rather than rot. If that holds, scanning further back is wasted effort and the move is to re-run the sweep once wago publishes a build newer than
69383. Not proven --69155also scores 2, so the trend is not clean._assert_not_shortwould refuse any of these anyway, so a premature bump fails the build rather than silently shipping a locale with entries missing. That guard is what turned this from a shipped defect into a caught one.wago.toolsreturns intermittent504s on large exports -- two separate fetches hit one during this work, and the regeneration failed twice before succeeding, once on the very last locale. Retry rather than concluding anything from a single failure. Cached tables make a retry cheap: the third attempt only had to fetch what the second had not.
Changed (shipped data)
Vanilla
requiredSkillcoverage drops about 25%, and that is the intended outcome of the trainer-map fix above rather than a side effect of it. TheData/Vanilla/_core/tree goes from roughly 1,551requiredSkillvalues to 1,163 across nine professions. Those ~388 recipes were taking their value from AzerothCore Wrath or TrinityCore Cata -- exactly the cross-expansion merge the fix removes.cmangos_classicyields 472 spells against AzerothCore's 1,549, so Vanilla has the thinnest same-era trainer source and pays the most for scoping.Checked as a design cost and not a parse bug before shipping it:
cmangos_tbcruns the identical flat parser and yields 791 precisely because a TBC world DB legitimately carries both eras' trainer rows, and most VanillarequiredSkillvalues come from the recipe-scroll tier (ItemSparse), not the trainer tier.The gap is shipped rather than the estimate because this file's own priority chain has said so since long before the question came up (
:825-835): a missingrequiredSkillships as absent so the gap stays visible, and shipping a heuristic "masks the data gap and produces a false sense of accuracy". A Wrath trainer row standing in for a Vanilla one is a heuristic. There is no rule that keeps the coverage and also fixes Smelt Silver --cmangos_classichas no row for Smelt Silver, Smelt Bronze, Smelt Tin or Engineering 12895, so any newer-source fallback reintroduces the defect exactly.The consumer note covers the tiers as well as the field, and that second half was nearly missed. Removing a
requiredSkilldoes not only blank a field -- it moves the recipe into the unanchored set, wheredifficulty[1]is a DBC placeholder of1rather than a real orange threshold. About 93% of the 388 land there (Vanilla carried ~1,551requiredSkillvalues across 1,565 recipes, so at most ~14 were unanchored before; it is 373 now, so at least 359 moved). A consumer told only "render-instead of0" would do exactly that and then print1as the threshold beside it -- a made-up number, in the release that makes the rule matter most. Everything written was true; the omission was the defect.docs/AUDIT.mdround 21.Full blast radius, measured by regenerating all five
_coretrees rather than sampled: Vanilla nine professions (values removed); TBC Engineering, Jewelcrafting, Leatherworking, Mining, RecipeItems; Wrath Engineering, Inscription, Jewelcrafting, Mining; Cata unchanged; Mists unchanged, with the four diverging files reverted. Consumers notified indocs/DEPENDENCY_CONTRACTS.md§6 before release, not after.
Documented (consumer rule)
When a recipe's crafting tiers are meaningless, that is now detectable from the shipped data alone -- and the obvious one-field version of the rule is wrong in both directions. A recipe's orange tier is a placeholder exactly when
requiredSkillis ABSENT, ANDdifficulty[1] == 1. It is the conjunction; neither condition alone will do.difficulty[1]has two writers, which is what breaks the single-field readings: the placeholder correction atbuild_authoritative_data.py:892, and the[min_rank, ...]construction 60 lines above it that takes the DBC'sMinSkillLineRankdirectly. Measured across all five shipped trees rather than argued:requiredSkillabsent butdifficulty[1] > 1-- 29 Vanilla, 18 Mists. Anchored by a realMinSkillLineRank; perfectly good data. A rule keyed on "requiredSkillis absent" discards 47 correct thresholds.requiredSkillpresent and== 1withdifficulty[1] == 1-- 192 across all trees, every one carryingrequiredSkillexactly 1. Apprentice recipes genuinely learnable at skill 1 and genuinely orange from 1. A rule keyed on "difficulty[1] == 1" condemns 192 correct ones.
Population of the real unanchored set: Vanilla 373, TBC 3, Wrath 245, Cata 284, Mists 824. Wrath, Cata and Mists were already carrying theirs before any change this week -- only Vanilla's is new, and it is the same population as the 388 above.
Written in two places on purpose, as a comment at
build_authoritative_data.py:892and indocs/DEPENDENCY_CONTRACTS.md§6, with the caveat that the rule holds only while those two remain the sole writers -- a third anchoring route would silently invalidate it while consumers kept applying it. That is the same species as theSOURCES-order defect fixed above: a rule living in an incidental property of the data rather than in code.DECIDED: no schema change. The two candidates were omitting
difficultywhen the orange tier is a placeholder, or shipping an explicit unanchored flag. Omitting is wrong -- only tier 1 is unanchored, while tiers 2-4 come from realTrivialSkillLineRankLow/High, so it discards three good values to hide one bad one. A flag is redundant -- it would make every consumer learn a new field for something the two existing fields already determine. The one real argument for a flag was robustness, since a documented rule can go stale; that is answered below by enforcement rather than by schema.The unanchored-tiers rule is now ENFORCED against the shipped tree, not just documented --
Tests/shippeddata_spec.lua, five specs, suite 164 -> 169. It walks all five game trees through their own TOCs and classifies every recipe, then asserts the invariant the contract rests on: no recipe ever shipsdifficulty[1] == 1alongside arequiredSkillabove 1. A third anchoring route, or a change to the correction's gate, turns the suite red instead of turning the contract into a lie.Two of the five specs pin the counter-example classes deliberately, and they are load-bearing rather than decoration: one asserts that recipes exist with
requiredSkillabsent anddifficulty[1] > 1, the other that recipes exist withrequiredSkill == 1anddifficulty[1] == 1. Each disproves one of the two tempting one-field restatements of the rule -- both of which were proposed during review and measured down. If either class ever empties, the conjunction has collapsed into a single condition and the contract should be simplified deliberately rather than by drift.Red-tested, not assumed green: injecting a fabricated
requiredSkillon one spell failed the invariant spec naming the recipe and all five expansions it appears in. A corpus assertion (> 10000recipes classified) guards the whole block against passing vacuously, and the TOC map asserts all five flavours were visited. Coverage unchanged at 417/417.
Added (API)
GetRecipeScrollPrefixnow returns the SAMPLE COUNT as a second value, atLibProfessionDB-1.0MINOR 11.local prefix, samples = DB:GetRecipeScrollPrefix(186) --> "Manual: ", 1 local prefix = DB:GetRecipeScrollPrefix(186) --> "Manual: " (unchanged)The prefix is a majority vote over real scroll names, and Mining's whole vote is one scroll -- so "Manual: Smelt Truesilver" is rendered for 22 synthetic descriptors on the strength of a single sample, while Blacksmithing's "Plans: " is derived from 224. That is not wrong, and suppressing a low-sample prefix would cost those 22 a usable header. The defect was that a consumer could not tell the two apart, so it had no basis on which to prefer its own heading. A threshold decides that for everyone, once, invisibly; a count lets each consumer decide and can be ignored for free.
First return unchanged and first, so no existing call site moves. A nil prefix keeps a nil count, never 0 -- "no prefix" and "a prefix from no samples" must stay distinguishable, and the second is not a state that can exist. The count is also nil for data generated before this shipped, which reads correctly as unknown rather than as zero.
Shipped as a second loader,
LoadRecipeScrollPrefixSamples, riding in the same per-localeRecipeScrollPrefixes.lua: the existing prefix table keeps its shape, so an older data file still loads and no consumer branches on a union type. Regenerated across five expansions and 24 locales. Ten new specs; coverage back to 100% (417/417).Requested by ItemDB on 2026-08-06 with "your call once you own it", and unanswered until now. Their instinct is what shipped -- the only thing that changed is where the number lives: in the data, rather than in a build report a human reads once.
Iterate()'s full walk now guards on the table it is about to index rather than on its sibling key. Same loop, same behaviour, both paths still specced -- but guardingpidwhile indexingslotleft the language server unable to narrow the type and reporting a possible nil index forever.
Added (data pipeline)
tools/check-build-pins.py --locales <expansion>-- find a build whose every locale export is actually complete. Written because a wrong diagnosis produced a wrong recommendation: the MoP pin bump was reported as blocked on wago's exports being "incomplete at source", which asserts the gap is in Blizzard's data. It is not. Measured:zhCNis short on5.5.4.69383and complete on5.5.4.69155, whilezhTWis short on both and worse on the latter. Individual(build, locale)exports fail independently, so the question is not "is wago fixed yet" but "which build is fully served" -- a bounded search rather than an open-ended wait, and this answers it.It streams with an early exit, and the exit helps the SHORT case rather than the complete one -- a distinction worth stating because getting it backwards is what made three scans time out. Ids ascend in the export, so a high id only appears near the end (
zhCNat5.5.4.69155reached id 100,011 at row 82,483 of 88,671, 93% of a 33 MB body), which means proving a locale complete costs almost the whole download. What the gate genuinely buys is the reverse: a short export is a clean contiguous prefix of the id space, so it is small and rejected fast. The practical consequence is to check the locale most likely to be short first.The gate is derived from
enUSon the same build, never hard-coded -- a literal would silently become wrong for another expansion, which is the exact class of defect this file's own header already warns about.HTTP
Rangewould make the whole thing trivial and wago does not support it. Since ids ascend, the last row carries the max id, so a tail read would answer completeness in one small request. Measured:Range: bytes=-4096returns HTTP 200 with the entire 32,875,405-byte body, not a206-- the responses are chunked with noContent-Length. Recorded so nobody re-derives it.--start-atskips builds already measured, because each one costs a fullenUSdownload for the reference alone.enGBis not fetched at all: it mirrorsenUS, so checking it would measure the same export twice and report a locale that is never downloaded.A TBC trainer source, which did not exist.
tools/emulator_data/carried cmangos-classic (Vanilla), azerothcore (Wrath) and TrinityCore (Cata) and nothing for TBC, so TBC was the one expansion where trainer-taught recipes had no authoritativerequiredSkillsource at all -- which is the structural reason hand-curated overrides were reached for there.tools/extract_emulator_trainers.pygains acmangos_tbcsource overcmangos/tbc-db, and the CMaNGOS prep is now parameterised by repo rather than copied, on the same reasoning as theis_obsoletefix above: a second near-identical implementation is how the first pair came to disagree. Run: 18 MB dump,npc_trainer+npc_trainer_templateextracted, 5,908 trainer rows parsed.Also fetched
SkillRaceClassInfofor2.5.5.67511(TBC Alchemy isSkillTierID41). Recorded so nobody retries it:SkillTiersis not on wago.tools at all -- it 404s on all five pinned builds and wago's own/db2index lists onlySkillLine,SkillLineAbility,SkillLineXTraitTree,SkillRaceClassInfo,TradeSkillCategoryandTradeSkillItem. The tier's max-skill value is not obtainable from that source under any table name.Why this went in now. I had answered a consumer's question with "cannot be measured from this repo" because the cache held no
SkillTiers-- mistaking the absence of a file in a cache for the absence of the data. A cache holds what was fetched, not what exists. The answer is withdrawn indocs/AUDIT.mdalong with what the measurements actually showed: TBC's ownSkillLineAbilitygives the twelve disputed Alchemy recipesTrivialSkillLineRankLowof exactly 385 and 390 -- the same numbers as the overrides, and the same columnbuild_authoritative_data.py:825-832explicitly refuses to use as a requirement.
Added (tests)
Tests/query_spec.luaandTests/bootstrap_spec.lua: 51 new specs, and line coverage goes from 78.68% to 100%. Suite 154 passed, 0 failed, 0 pending;LibProfessionDB-1.0.lua395/395 andProfessionDB.lua13/13.The uncovered block was not an odd corner -- it was the surface consumers actually call.
Search,Iterate,IterateEnchants,EnchantsForSlot, the lazy enchant-index build and all ten field getters had never executed under test, andProfessionDB.luawas at 0/13: the bootstrap, itsPLAYER_LOGINframe and the VersionCheck-1.0 host had never run at all.Specced for the properties that fail silently rather than for the line count: every getter answering nil for an unknown recipe and an unloaded profession; the enchant index picking up recipes, enchants and names loaded after it was first built (driven from outside, so a fifth loader that forgot to invalidate fails here rather than passing a code read);
Searchtreating its query as literal text and not a Lua pattern;Iterate()skipping an empty profession instead of stopping at it, which is reachable becauseLoadCore(profId, {})creates the slot; and both sides of every feature-detect in the bootstrap, including the bare-globalGetAddOnMetadatabranch that no client running this suite would take and the three ways VersionCheck can be absent.Iterator assertions sort or count rather than indexing a position, since
nextorder is not stable, and the getter loops assert their own iteration count so an empty case table cannot report a pass having checked nothing.
Known, and not fixed here
Line coverage is 78.68% (321/408), not 100%.
ProfessionDB.luais 0/13 -- the bootstrap, itsPLAYER_LOGINframe and the VersionCheck-1.0 host have never executed under test -- andLibProfessionDB-1.0.luais 321/395, with the uncovered block being precisely the surface consumers call:Search,Iterate,IterateEnchants,GetEnchantsForSlot, the enchant index build, and the ten one-line getters at:646-687. This corroborates the reviewer's own round-1 "Not covered" list rather than contradicting it. On the repo todo list; not attempted in this change.Superseded later in this same unreleased entry -- see "Added (tests)" above. Coverage is now 417/417, 100.00%, across both files. The paragraph is left standing rather than rewritten because it records what was true when the gap was found.
_corefiles carry NO BUILD STAMP, and that is a PROVENANCE gap rather than a tidiness one. "Which DBC build generated this file?" is answerable only fromRecipeItems.lua; every_core/<Profession>.luais silent. It is what makes the MoP zero-diff above unverifiable -- a correct rebuild and a skipped one are indistinguishable in the output. Not fixed here because emitting a stamp rewrites every_corefile across five expansions and this release is already 97 paths, but recorded at its real severity: after two days spent on build provenance, this is the artefact that would have answered most of the questions.docs/AUDIT.mdround 17.Related and smaller: every
_coreheader still credits "the TOGProfessionMaster authoritative-data pipeline", which moved into this repo in v1.6.0. Same reason for deferring, same file set.A recipe with no
requiredSkillshipsdifficulty[1] = 1, which is a wrong number rather than a visible gap -- and the trainer-map fix above gives that behaviour 388 more recipes in the Vanilla tree. The tier correction atbuild_authoritative_data.py:858is gatedif difficulty[0] <= 1 and required_skill and required_skill > 1, so with norequiredSkillthe guard fails anddifficulty[0]keeps the DBC placeholder 1. Concretely, Vanilla Cooking spell 6499 goes from{50, 90, 110, 130}to{1, 45, 90, 130}. An orange threshold of 1 is not an omitted field a consumer can render as "-"; it is a number a player reads and acts on, so the gap-is-visible argument that justifies dropping the 388 does not extend to it. The behaviour predates this change -- every recipe with norequiredSkillwas already doing it -- but the larger population is this change's doing. Deliberately not fixed here: the honest remedy is either to omitdifficultyentirely whendifficulty[0]is the placeholder, or to ship a flag marking the tiers unanchored, and both are shipped-schema decisions with consumers on the other end (TOGProfessionMasterreadsdifficulty). Raised indocs/AUDIT.mdand flagged to consumers indocs/DEPENDENCY_CONTRACTS.md§6.Superseded within this entry -- the deferral was wrong and the review said so. Shipping the removal while filing the placeholder as a follow-up trades a possibly-wrong
requiredSkillfor a definitely-wrongdifficulty[1], 388 times, using the very argument that rules the trade out: the change that makes a value absent must not arrive before the code that handles absence. The precondition is now met without the schema decision, because the signal is already in the shipped data -- see "Documented (consumer rule)" below. The schema question (omitdifficulty, or add an explicit flag) stays a genuine follow-up rather than a gate.The per-localename/effectfiles were not regenerated with the_coretree. The rebuild ran--core-only, which is correct for a change that touches only locale-independent fields and avoids refetching twelve locales'ItemSparse-- but it means theData/<game>/<locale>/files have not been re-verified against the new_core. The suite's shipped-data specs pass against the stitched view, which is evidence but not the same as a rebuild.CLOSED, by running it rather than reasoning about it. A full rebuild (no
--core-only) across all five games and twelve locales -- 202,560 name entries -- produced a diff byte-identical to the--core-onlyrun: not oneData/<game>/<locale>/<Profession>.luachanged. That is the expected result and now a measured one, because the trainer-map fix touches onlyrequiredSkillanddifficulty, which are_corefields, whilenameandeffectcome fromSpellNameandSpellItemEnchantment.--core-onlywas the right call and the tree is consistent.The run also cleared the standing worry about the short MoP exports: at the current pins (
5.5.3.67509)_assert_not_shortdid not fire, andData/Mists/zhCN/andData/Mists/zhTW/RecipeScrollPrefixes.luaboth carry all 10 professions including 755 (Jewelcrafting) and 773 (Inscription).
[v1.6.0] (2026-08-08) - Recipe sources move here, recipe scrolls reach all five game versions, the whole build pipeline moves here, and never-implemented recipes
New Features
GetRecipeSources(spellID)at MINOR 10 — where a recipe comes from, with the npc ids AND their names. Moved from TOGProfessionMaster, which shipped it as twelve files, 380,088 lines and 6.7 MB of a single all-expansion merge loaded by every client: a Vanilla player carried Cata's drop tables.It now ships per version, in 968 KB for all five combined — and carries more than it did before, not less:
DB:GetRecipeSources(spellID)→{ trainer = { { id, name }, … }, vendor = …, quest = …, container = …, drop = … }, or nil.DB:GetRecipeSourceKinds(spellID)→{ trainer = true, … }, the cheap form for a caller that only renders labels.DB:GetSourceName(npcID)→ the English name.
Each list carries
.total, which is NOT always#list. Lists are capped at 12 ids because 1,649 recipes drop from 50+ creatures apiece — Rough Grinding Stone from 951. Uncapped, the five versions came to 9.5 MB to say "drops from everything". Read.totalfor a count; iterate the list for examples.Names are English only, from the emulator world databases'
creature_template, which is not localized — unlike recipe names, which ship in 12 languages. New per-version_core/Sources.lua+_core/SourceNames.lua, wired into all five TOCs.The entire data pipeline now lives in this repo. It was in TOGProfessionMaster and wrote across the repo boundary into this one, so ProfessionDB could not rebuild itself. All eleven tools moved, plus their caches.
tools/build_authoritative_data.pyregenerates the wholeData/tree byte-identically, verified before and after the move.New
tools/pdb_common.pyholds the build pins and shared helpers once.tools/build-recipe-items.pycould not run at all here (it still imported ItemDB'sitemdb_common) and now does. Two tools carried the same_core-parsing function under different names, already diverged; there is one now.
Bug Fixes
GetRecipeItemanswerednilfor EVERY recipe on Wrath, Cata and Mists — the recipe-scroll data was only ever shipped for Vanilla and TBC. When the scroll data moved here fromLibItemDB-1.0at MINOR 8, onlyData/Vanilla/_core/RecipeItems.luaandData/TBC/_core/RecipeItems.luawere generated and wired into their TOCs. No such file existed on disk for the other three flavours, so on a Wrath, Cata or MoP clientlib.recipeItems,lib.skillRankBooksandlib.syntheticRecipesall stayed empty andGetRecipeItem(spellID)returnednil, falseunconditionally — which is indistinguishable from the legitimate "this recipe is trainer-taught" answer, so nothing surfaced it. Two consequences for a consumer: the real teaching-item tooltip never resolved (TOGProfessionMaster falls back to the crafted item), and skill-rank books could not be filtered out, becauseisRankBookis exactly what a consumer uses to exclude them — so Expert Cookbook (3413), the two First Aid manuals (7924 / 10846) and the Fishing books (7732 / 18248) rendered as craftable recipes with a nonsensedifficultyof{125,125,125,125}.tools/build-recipe-items.pyhas been run for all three, and the outputs plus their per-localeRecipeScrollPrefixes.luafiles wired intoProfessionDB_Wrath.toc,ProfessionDB_Cata.tocandProfessionDB_Mists.toc. Coverage lands where the generator's own honesty notes say it should — Mining stays ~5%, Herbalism and Skinning 0%, First Aid low:- Wrath — 2,026 / 3,710 (54.6%), 10 rank books, 1,684 synthetic
- Cata — 2,520 / 4,406 (57.2%), 10 rank books, 1,886 synthetic
- Mists — 3,026 / 5,456 (55.5%), 6 rank books, 2,430 synthetic
Caught by
Tests/shippeddata_spec.lua, which fails when a data file on disk is in no TOC — it flagged all 36 new locale prefix files before they were wired.Never-implemented recipes were being missed, because AllTheThings records the fact TWO ways and the extractor read one. A node can sit under ATT's NeverImplemented tree (by position) or carry
u = 1while sitting in the ordinary Professions tree (by flag). Only the first was handled, and the node constructor discarded the opts table entirely, souwas never even read.Reported against TOGProfessionMaster's Missing Recipes tab: Darkspear (
16987,{awp=70105,requireSkill=9787,u=1}, filed under Blacksmithing > Weaponsmith) sat in players' lists while ATT's own tooltip said "This was never available to players."A second miss surfaced with it: the flag pass also required a profession ancestor, so Steam Tonk Controller (28327) — filed under a zone and a holiday,
u=1,awp=20300— was skipped too. Au = 1recipe qualifies wherever ATT happens to have filed it.Difficulty tiers could DESCEND, and ~15% collapsed orange onto yellow. Reported in game as
185 185 205 225. Two causes in two code paths:SkillLineAbility.MinSkillLineRankis a placeholder1for scroll and drop recipes, so the builder substitutesrequiredSkill— but then set yellow toTrivialSkillLineRankLowor orange itself when that was not higher. Yellow is now interpolated between orange and green instead.- The other path had no ordering guard at all, so where
MinSkillLineRankexceededTrivialSkillLineRankLowgreen landed below yellow: Cooking 2548 shipped[135, 135, 130, 170], Blacksmithing 2665[75, 75, 65, 80].
Descending tier sets across all five versions: 37 → 0. Duplicated orange/yellow: 2,651 → 338. The remaining 338 are deliberate — those recipes have
TrivialLow == TrivialHigh, one threshold and no band, and inventing a spread would be fabrication.IsHiddenRecipe(spellID)at MINOR 9 — is this a recipe that was never obtainable in the live game? Some recipes shipped in the client's spell tables and nothing in the world ever taught them: no trainer, no vendor, no drop, no quest.No client signal reveals this. The spell resolves, the crafted item resolves, the reagents resolve, and the recipe sits in
SkillLineAbilityexactly like a real one — so every generic "is this real on this client" check passes it. The only distinguishing fact is historical, which is why it has to ship as data.DB:IsHiddenRecipe(spellID)→truewhen the recipe was never obtainable.DB:GetHiddenRecipes()→ the raw{ [spellID] = true }map, read-only.
Feature-detect it (
if DB.IsHiddenRecipe and DB:IsHiddenRecipe(id)) so the call stays inert against an older ProfessionDB.Deliberately narrower than "unobtainable": it does not cover recipes that were once obtainable and later removed, nor holiday-gated ones. An id absent from the list is not thereby proven obtainable.
IsAutoTaughtRecipe(spellID)/GetAcquireMethod(spellID), also MINOR 9 — the other half of "why does this recipe say Unknown?". Trainer, vendor, drop and quest sources all come from community server databases, and a residue is left that none of them mention — 87 of 1,261 Vanilla recipes.26 of that residue is not missing data.
SkillLineAbility.AcquireMethod = 1means the recipe is granted when the character learns the profession, so nothing in the world teaches it: Minor Healing Potion, Rough Sharpening Stone, Linen Bandage, Smelt Copper. A browser should say "Learned with profession", not "Unknown". Verified to discriminate rather than assumed — those read1while Bleakwood Hew, a world drop, reads0.The raw value is shipped, not a boolean: TBC and Wrath each carry three recipes with
AcquireMethod = 3, which a boolean would silently discard.0is omitted, being the default and the large majority. New per-expansion data fileData/<Version>/_core/AcquireMethods.lua(28 / 35 / 39 / 35 / 62 entries), built bytools/build-acquire-methods.py, wired into all five TOCs.New data file per expansion,
Data/<Version>/_core/HiddenRecipes.lua— 67 Vanilla / 87 TBC / 129 Wrath / 184 Cata / 231 Mists never-implemented recipes. Of those, 25 / 37 / 41 / 58 / 90 are recipes this library actually ships; the rest are kept anyway, so adding recipes later cannot silently un-hide one. Wired into all five TOCs.Counts include the two extraction fixes under Bug Fixes — 11 more shipped recipes are correctly hidden than the first pass found.
New build tooling,
tools/build-hidden-recipes.py+tools/att-nyi-extract.lua. The extractor evaluates AllTheThings' data under a Lua interpreter with stubbed constructors rather than parsing it — ATT ships each category file as a single minified line, one of them 1.5 MB wide, so text-matching would be guesswork. Same technique as LibItemDB'stools/att_extract.lua. Only nodes under ATT's Professions header (-44) and Recipes filter (200) are taken, so the ~2,800 hidden non-recipe items in the same tree stay LibItemDB's territory. The script refuses to write an empty file, which would silently un-hide everything.
Improvements
The test suite now catches a data file that no TOC lists — the exact regression this addon has already shipped. Vanilla's 13 Fishing files sat on disk, named by no TOC, executed by nothing, with every spec green. The guard written after that incident iterates TOC entries, so a file no TOC names produces no entry and stays invisible to it: it protects the other direction.
Tests/shippeddata_spec.luanow also walksData/off disk and requires every.luaunder it to appear in at least one of the five TOCs. It matters because the generator writes files per flavour × locale × profession while the TOC entries are a separate edit, five times over — miss one and that flavour silently has no data for that profession, with consumers rendering an empty list rather than an error. Raised asdocs/AUDIT.mdfinding 1.
Credits
- The never-implemented list is derived from
AllTheThings (MIT). Their
files are not redistributed — the extractor reads an installed copy at build
time and only the resulting spell ids are re-expressed in this library's own
format. A new Credits section in
README.mdrecords this alongside wago.tools, the source of everything else.
[v1.5.0] (2026-08-06) - Recipe scrolls move here from ItemDB; ids rebuilt from DBC
New Features
Recipe-scroll data now lives here, at
LibProfessionDB-1.0MINOR 8. It moved fromLibItemDB-1.0because every part of it is keyed by craft spell id — exactly asrecipesalready is — so it is recipe data, not item data. ItemDB keepsGetLink/GetName, which answer questions about an item, and a consumer now asks each library the question it owns.GetRecipeItem(spellID)→itemID, isRankBook— the scroll that teaches a recipe, or nil, which is a real answer for roughly a third of recipes: they are trainer-taught and no such item exists.GetSyntheticRecipeScroll(spellID)→ a scroll-shaped descriptor for exactly those. Its exact complement — every recipe answers one or the other, never both — so a consumer can draw one tooltip shape throughout instead of a seam where the real scrolls run out. It carries no item id and never will: a fabricated id is permanently cache-cold and fails silently in every item API.GetRecipeScrollPrefix(skillLineID)→ the localized prefix ("Plans: ", and note frFR is"Plans : "with a space before the colon while zhCN uses a full-width one). Derived at build time from real scrolls, never transcribed.AttachExternalRecipeInfo(tooltip, spellID)→ attaches AllTheThings' lines to any tooltip. Keyed by spell, so it works for recipes with no scroll — the case that would otherwise get nothing. Never raises; ATT is optional.
requiredSkillis deliberately absent from the synthetic descriptor. ItemDB shipped it and it was wrong for ~313 of 572 records: it readSkillLineAbility.MinSkillLineRank, which is a floor of 1 on eight of twelve skill lines rather than the skill needed, and rendered "Requires Mining (1)" on a recipe requiring 230. This library already carries the correct per-recipe value on the recipe, so a second copy could only ever let the two disagree.
Bug Fixes
The TBC TOC was named
ProfessionDB_BCC.toc, which no WoW client reads._BCCis not a flavour suffix the game recognises, so on a TBC client the file was skipped entirely and the game fell back to the baseProfessionDB.toc— Interface 11509, Vanilla recipes, flagged out of date in the AddOns list. Nothing errored; TBC users simply had the wrong expansion's data. Renamed toProfessionDB_TBC.toc.Settled by counting Blizzard's own shipped TOCs rather than from memory:
_Vanilla40,_TBC37,_Wrath43,_Cata56,_Mists103,_Mainline156, and_BCCzero. ItemDB and TOGProfessionMaster were already on_TBC; this library was the only one out of step. Location:*.toc,CLAUDE.md,wow-version-replication.ps1.All 13 Vanilla Fishing data files shipped in the package and were listed by no TOC, so they were installed on every Vanilla client and never executed — the same shape as the wrong-library-handle fault above, one layer earlier. Every other flavour lists its 13; Vanilla listed none. Now wired in after
Firstaid, matching the alphabetical order the other four use.Vanilla Fishing's two entries are both skill-rank books rather than recipes (spells 7732 and 18248 — Expert Fishing - The Bass and You and its Artisan counterpart), which is precisely what
GetRecipeItem'sisRankBooksecond return exists to let a consumer filter out. Location:ProfessionDB.toc.itemId— the recipe-scroll item — was wrong on 42 Vanilla recipes, and invented on ~54 more. It was produced by a name-match linker in the generator: strip"Plans: "/"Pattern: "from an item name and match the remainder against a spell name. Every one of the 42 was a Season of Discovery reissue where the matcher bound the original Vanilla scroll to the seasonal spell. The clearest case is item 228118, whose display name reads "Plans: Stronger-hold Gauntlets" while the spell it actually teaches is Stronghold Gauntlets — so a matcher pairs it with the seasonal recipe on its face, and the original loses its scroll.Replaced with the authoritative DBC join,
ItemEffect.SpellID → SpellEffect[Effect=36].EffectTriggerSpell, which resolved 1,877 item→craft links on Vanilla alone. Also locale-independent, where the matcher's prefix list was English-only and could never have worked for adeDEorzhCNpull.A teaching item that does not exist could win the tie-break. Where several items teach one spell the lowest id is kept — but
ItemEffectcan name an id with noItemSparserow at all, and those ghosts sort low. Recipe 7443 picked 6221 (nameless) over 6342 Formula: Enchant Chest - Minor Mana, and 25124 picked 20751 over 20758 Formula: Minor Wizard Oil. Candidates must now exist.
Improvements
Cross-checked against a second, independent extraction. ItemDB derived the same mapping separately and the two now agree on 1,071 of 1,071 shared recipes, up from 987 of 1,029. Neither bug above was findable by a spec — both shipped a plausible number — and only two extractions disagreeing exposed them.
Offline test suite, on the shared WoWAPITesting harness. 40 specs covering the flavour gate every data file guards on, the core/names stitch, and the fact that the applied-enchant catalogue is a separate id space from recipes:
2543is Westfall Stew as a craft spell and "Attack Speed +1%" as aSpellItemEnchantment. That last one is pinned because two independent scripts mis-parsed it the same way —Enchanting.luacallsLoadCoreat the top andLoadEnchantsCore3,700 lines down, and a parse anchored to the file rather than the loader sweeps them together. Dev-only; not shipped in the package.Tests/shippeddata_spec.luaexecutes the REAL data files, and it caught a release-blocking bug in this very release. All 26 of the moved files still opened withLibStub("LibItemDB-1.0")— the handle they were generated with before the move — so Vanilla's 1,073 recipe items and 572 synthetic records, plus 24 prefix files (12 locales across both flavours), loaded into ItemDB's tables while this library's stayed empty. Every query returned nil and consumers silently fell back; nothing errored anywhere. Fixed in the shipped files and in the generator, so a rebuild cannot reintroduce it.The reason it got as far as it did is the part worth keeping: the earlier specs called the loaders with their own fixtures, which proves a loader works and never that a shipped file reaches one, and a whole-TOC load check counted the silent
returnas a pass. These run the files off disk in TOC order and assert the data arrived. Verified by mutation — restoring the old handle in a single file fails four of them.It now walks every TOC discovered on disk, asserting it finds all five, rather than a hard-coded pair. Naming the files was how it broke the moment the TBC TOC was renamed, and covering only two of five was how Vanilla's unlisted Fishing files went unexamined — nothing loaded them, so nothing checked them. The count assertion is deliberate: a bare glob that found nothing would let every assertion in the file pass vacuously.
Recorded a real data gap found while writing those specs: esES ships only 2 of 9 scroll prefixes (Blacksmithing and Enchanting), because the upstream DBC strings for the rest are absent in that locale — esMX has all 8. Not a load failure, so the locale spec asserts non-empty rather than per-profession coverage, and this is noted as its own future fix.
Offline harness pinned at
502e31b, and the per-flavour interface numbers this library ships are now what the harness's ownwow.setBuild(flavour)is built on — it had no authoritative source for them and had declined to guess. CorrectedCLAUDE.md, which still documented Era as 11508, BCC as 20505 and Mists as 50503; the TOCs ship 11509, 20506 and 50504. Nothing branched on the stale numbers (every 1.15.x is< 20000, which is all the flavour gate reads), but a wrong number in a doc that another repo builds on is how it becomes a real one.docs/AUDIT.md— the standing peer-review conversation, the inverse of a harness contract: findings are raised by a review session and answered here. Created so a review has somewhere to land, and pointed at fromCLAUDE.md, which is the part that fires unconditionally.
Older releases (v1.4.1 and earlier) are in CHANGELOG_ARCHIVE.md.
This mod has no additional files