From 7e5c58d1ca319adbe9872b85271fa6fb89f444ba Mon Sep 17 00:00:00 2001 From: Florian Berthold Date: Fri, 29 May 2026 10:43:54 +0200 Subject: [PATCH] feat(catalog): wire up Icons collector (TOC + router); fix MysticScrollProbe nil crash --- CoaExporter/Catalogs/Icons.lua | 206 +++++++++++++++++++ CoaExporter/CoaExporter.toc | 1 + CoaExporter/Collectors/MysticScrollProbe.lua | 2 +- CoaExporter/Core.lua | 6 +- README.md | 10 +- 5 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 CoaExporter/Catalogs/Icons.lua diff --git a/CoaExporter/Catalogs/Icons.lua b/CoaExporter/Catalogs/Icons.lua new file mode 100644 index 0000000..db3235c --- /dev/null +++ b/CoaExporter/Catalogs/Icons.lua @@ -0,0 +1,206 @@ +-- CoaExporter / Catalogs / Icons.lua +-- +-- Captures the realm-server-resolved icon paths the MoA UI actually +-- renders, which neither CharacterAdvancementData.json nor any client +-- DBC carries: +-- +-- 1. Per-entry icon (`entry.Icon`) — the talent-grid node icon. The +-- Skills/Talents collectors above use GetSpellInfo's icon, which +-- is empty for CoA-custom entries that don't back to a real spell +-- (every spec-root Talent like ID 31202 "Fortitude Venomancer" = +-- the Venomancer beetle node, plus ~3800 other CoA-custom rows). +-- `entry.Icon` is what the realm gives the UI; we capture both. +-- +-- 2. Per-(class, spec) sidebar tab icon — the small icon on each +-- vertical spec tab in the Character Advancement window, fetched +-- via `C_ClassInfo.GetSpecInfo(classFile, specFile).SpecFilename`. +-- Same source the UI itself uses (see Ascension's +-- Ascension_CharacterAdvancement/Templates/CAGate.lua:179). +-- +-- Output (in CoaExporterCatalog): +-- iconByEntryId = { [entryId] = "icon-name" } +-- specInfoByFileString = { [] = { +-- [] = { Name=..., SpecFilename=... } } } +-- +-- Class-keyspace: every spec is keyed by the WoW classFile (UPPERCASE, +-- the second return of UnitClass) like "PROPHET", "MONK", "FLESHWARDEN" — +-- the same key C_ClassInfo.GetSpecInfo wants. Avoids the entry.Class → +-- file_string alias mess (Venomancer → PROPHET, Templar → MONK, …) by +-- never recording entry.Class as a primary key. +-- +-- For sidebar icons we also brute-force `KNOWN_CLASS_FILES × KNOWN_TABS` +-- regardless of what the entry-walk found this session — Ascension's +-- C_CharacterAdvancement.GetAllEntries() is class-scoped (only returns +-- entries the active character can reach), so a Venomancer alt may not +-- see Templar entries and vice-versa. C_ClassInfo.GetSpecInfo is +-- keyed (class, spec) directly though, so we can ask about every CoA +-- combination without the entry-walk needing to have surfaced it. +-- +-- iconByEntryId is still entry-walk gated (we need entry.ID + entry.Icon), +-- so per-talent icons accumulate across runs as you rotate characters. + +CoaExporter = _G.CoaExporter or {} +_G.CoaExporter = CoaExporter +local AE = CoaExporter +local C = AE.Catalog + +-- All file_strings in coa-db `class.file_string`. Vanilla 10 + DK + 21 +-- Ascension custom. The "HERO" pseudo-class is intentionally omitted — +-- it's not a real class and GetSpecInfo wouldn't have data for it. +local KNOWN_CLASS_FILES = { + -- vanilla + "WARRIOR", "PALADIN", "HUNTER", "ROGUE", "PRIEST", + "DEATHKNIGHT", "SHAMAN", "MAGE", "WARLOCK", "DRUID", + -- Ascension CoA custom (class.file_string) + "BARBARIAN", "WITCHDOCTOR", "DEMONHUNTER", "WITCHHUNTER", "STORMBRINGER", + "FLESHWARDEN", "GUARDIAN", "MONK", "SONOFARUGAL", "RANGER", + "CHRONOMANCER", "NECROMANCER", "PYROMANCER", "CULTIST", "STARCALLER", + "SUNCLERIC", "TINKER", "PROPHET", "REAPER", "WILDWALKER", "SPIRITMAGE", +} + +-- Every internal Tab name seen in CharacterAdvancementTabTypes.dbc +-- (94 unique values). Hardcoding lets us probe specs that the current +-- character's GetAllEntries doesn't surface — Venomancer's +-- Fortitude/Stalking/Vizier/Venom in particular. +local KNOWN_TABS = { + "Affliction", "Ancestry", "Animation", "Arcane", "Archery", "Arms", + "Assassination", "AstralWarfare", "Balance", "BeastMastery", + "Blessings", "Blood", "Boltslinger", "Brewing", "Brutality", "Bulwark", + "Combat", "Corruption", "Darkness", "Death", "Defiance", "Demonology", + "Destruction", "Discipline", "Displacement", "Domination", "Draconic", + "Duality", "Dueling", "Elemental", "Enhancement", "Felblood", "Feral", + "Ferocity", "Fighting", "Fire", "Firearms", "Fleshweaver", "Fortitude", + "Frost", "Fury", "Geomancy", "Gifts", "Gladiator", "Godblade", + "Hellfire", "Holy", "Hydromancy", "Incineration", "Influence", + "Inquisition", "Inspiration", "Invention", "Life", "Lightning", + "Marksmanship", "Mechanics", "Moonbow", "MountainKing", "Packleader", + "Piety", "Primal", "Protection", "Reaping", "Restoration", + "Retribution", "Riftblade", "Rime", "Runes", "Runic", "Seraphim", + "Shadow", "Shadowhunting", "Slaying", "Soul", "Stalking", "Subtlety", + "Survival", "Tactics", "Tides", "Time", "Unholy", "Valkyr", "Venom", + "Vizier", "Voodoo", "War", "Wind", "WitchKnight", +} + +-- Map entry.Class (the in-game advancement string) -> classFile, for the +-- entry-walk stream. Mirrors load_moa_talents.py's CLASS_NAME_TO_FILE_STRING. +-- Used only to bucket entry icons; the spec-info brute force iterates +-- KNOWN_CLASS_FILES directly so this only needs to cover what we actually +-- see in entry.Class strings. +local CLASS_NAME_TO_FILE_STRING = { + Warrior = "WARRIOR", + Paladin = "PALADIN", + Hunter = "HUNTER", + Rogue = "ROGUE", + Priest = "PRIEST", + DeathKnight = "DEATHKNIGHT", + Shaman = "SHAMAN", + Mage = "MAGE", + Warlock = "WARLOCK", + Druid = "DRUID", + Barbarian = "BARBARIAN", + WitchDoctor = "WITCHDOCTOR", + DemonHunter = "DEMONHUNTER", + Felsworn = "DEMONHUNTER", + WitchHunter = "WITCHHUNTER", + Stormbringer = "STORMBRINGER", + KnightOfXoroth = "FLESHWARDEN", + Fleshwarden = "FLESHWARDEN", + Guardian = "GUARDIAN", + Monk = "MONK", + Templar = "MONK", + SonOfArugal = "SONOFARUGAL", + Bloodmage = "SONOFARUGAL", + Ranger = "RANGER", + Chronomancer = "CHRONOMANCER", + Necromancer = "NECROMANCER", + Pyromancer = "PYROMANCER", + Cultist = "CULTIST", + Starcaller = "STARCALLER", + SunCleric = "SUNCLERIC", + Tinker = "TINKER", + Prophet = "PROPHET", + Venomancer = "PROPHET", + Reaper = "REAPER", + Wildwalker = "WILDWALKER", + Primalist = "WILDWALKER", + SpiritMage = "SPIRITMAGE", + Runemaster = "SPIRITMAGE", +} + +local function tablen(t) + if not t then return 0 end + local n = 0 + for _ in pairs(t) do n = n + 1 end + return n +end + +C.Register({ + name = "icons", + + onStart = function(_) + -- Don't wipe — accumulate across characters/runs so the user can + -- /coae catalog icons after each Reborn class swap and end up with + -- a complete catalog without re-collecting what was already done. + CoaExporterCatalog.iconByEntryId = CoaExporterCatalog.iconByEntryId or {} + CoaExporterCatalog.specInfoByFileString = CoaExporterCatalog.specInfoByFileString or {} + -- Drop the legacy field if it exists from earlier runs — single + -- source of truth keeps the loader simple. + CoaExporterCatalog.specInfoByClassSpec = nil + end, + + onEntry = function(_, entry, info) + local id = tonumber(entry.ID) or entry.ID + local serverIcon = (type(entry.Icon) == "string" and entry.Icon ~= "") and entry.Icon or nil + local fallback = (info.icon and info.icon ~= "") and info.icon or nil + local pick = serverIcon or fallback + if id and pick then + CoaExporterCatalog.iconByEntryId[id] = pick + end + end, + + onFinish = function(_) + local resolved, attempted = 0, 0 + + if C_ClassInfo and C_ClassInfo.GetSpecInfo then + for _, classFile in ipairs(KNOWN_CLASS_FILES) do + for _, tab in ipairs(KNOWN_TABS) do + attempted = attempted + 1 + local upperSpec = string.upper(tab) + local ok, r = pcall(C_ClassInfo.GetSpecInfo, classFile, upperSpec) + if ok and r and r.SpecFilename and r.SpecFilename ~= "" then + CoaExporterCatalog.specInfoByFileString[classFile] = + CoaExporterCatalog.specInfoByFileString[classFile] or {} + CoaExporterCatalog.specInfoByFileString[classFile][tab] = { + Name = r.Name or tab, + SpecFilename = r.SpecFilename, + } + resolved = resolved + 1 + end + end + end + end + + local totalIcons = tablen(CoaExporterCatalog.iconByEntryId) + local totalClasses, totalSpecs = 0, 0 + for _, tabs in pairs(CoaExporterCatalog.specInfoByFileString) do + totalClasses = totalClasses + 1 + totalSpecs = totalSpecs + tablen(tabs) + end + + if C_ClassInfo and C_ClassInfo.GetSpecInfo then + C._log(string.format( + "icons: brute-forced %d (class,spec) probes, %d resolved this run; " + .. "cumulative: %d entry icons, %d specs across %d classes", + attempted, resolved, totalIcons, totalSpecs, totalClasses)) + else + C._log(string.format( + "icons: %d entry icons cumulatively (C_ClassInfo.GetSpecInfo unavailable - sidebar icons skipped)", + totalIcons)) + end + end, +}) + +-- Exposed for the loader (load_moa_icons.py) to keep the entry.Class +-- alias table in lockstep with this addon. Not used in-game. +AE._iconsClassNameToFileString = CLASS_NAME_TO_FILE_STRING +AE._loadedCatalogIcons = true diff --git a/CoaExporter/CoaExporter.toc b/CoaExporter/CoaExporter.toc index d959055..63386df 100644 --- a/CoaExporter/CoaExporter.toc +++ b/CoaExporter/CoaExporter.toc @@ -18,6 +18,7 @@ Collectors\MysticScrollProbe.lua Catalogs\Common.lua Catalogs\Skills.lua Catalogs\Talents.lua +Catalogs\Icons.lua UI\ExportFrame.lua Core.lua diff --git a/CoaExporter/Collectors/MysticScrollProbe.lua b/CoaExporter/Collectors/MysticScrollProbe.lua index f4bad84..b67aeb9 100644 --- a/CoaExporter/Collectors/MysticScrollProbe.lua +++ b/CoaExporter/Collectors/MysticScrollProbe.lua @@ -65,7 +65,7 @@ function AE.CollectMysticScrollProbe() slotData.spellID = spellID local name, _, icon = GetSpellInfo(spellID) slotData.name = name - slotData.icon = icon and icon:match("Interface\\Icons\\(.+)"):lower() or nil + slotData.icon = icon and (icon:match("Interface\\Icons\\(.+)") or icon):lower() or nil slotData.tooltip_lines = TipToLines(function(t) t:SetHyperlink("spell:" .. spellID) end) diff --git a/CoaExporter/Core.lua b/CoaExporter/Core.lua index eb14d68..99d6877 100644 --- a/CoaExporter/Core.lua +++ b/CoaExporter/Core.lua @@ -378,7 +378,7 @@ CoaExporter commands: /coae export all|talents|spellbook|gear|enchants /coae export mdgear|mdenchants|mdspellbook|md (full wiki) -/coae catalog all|skills|talents +/coae catalog all|skills|talents|icons /coae catalog dispels [class]|passives [class]|status /coae scrolls scan|export|reset|status /coae sv on|off (SavedVariables for character export) @@ -408,7 +408,7 @@ local function HandleCatalog(rest) local sub, arg = rest:match("^(%S+)%s*(.*)$") sub = sub or rest - if sub == "all" or sub == "skills" or sub == "talents" then + if sub == "all" or sub == "skills" or sub == "talents" or sub == "icons" then if not AE.Catalog or not AE.Catalog.Run then DEFAULT_CHAT_FRAME:AddMessage("CoaExporter: catalog module not loaded") return @@ -432,7 +432,7 @@ local function HandleCatalog(rest) tostring(meta.lastScanAt or "never"), tostring(meta.filter or "-"), nSkills, nDispels, nPassives, nTalents)) else - DEFAULT_CHAT_FRAME:AddMessage("CoaExporter: /coae catalog [all|skills|talents|dispels|passives|status]") + DEFAULT_CHAT_FRAME:AddMessage("CoaExporter: /coae catalog [all|skills|talents|icons|dispels|passives|status]") end end diff --git a/README.md b/README.md index 125a687..4a4d044 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,12 @@ once per account; results persist across sessions and characters. ### Game-data catalog (for db.exil.es / talent-calc) ``` -/coae catalog all One pass: dump skills + talents +/coae catalog all One pass: dump skills + talents + icons /coae catalog skills Dump skills/dispels/passives only /coae catalog talents Dump talent-tree nodes only +/coae catalog icons Dump server-resolved per-entry icons + + per-(class, spec) sidebar tab icons via + C_ClassInfo.GetSpecInfo (for db.exil.es) /coae catalog dispels [class] Print dispel summary (or for one class) /coae catalog passives [class]Print level-passive summary (or for one class) @@ -117,7 +120,7 @@ SavedVariables | `CoaExporterSaved` | `/coae export …` when sv = on | Latest character export, keyed by `realm:name` | | `CoaExporterConfig` | `/coae sv on/off` | Just `enableSavedVariables` for now | | `CoaExporterScrollCache` | `/coae scrolls scan` | Mystic scroll item tooltips | -| `CoaExporterCatalog` | `/coae catalog …` | `skills`, `dispels`, `levelPassives`, `talents`, `_meta` | +| `CoaExporterCatalog` | `/coae catalog …` | `skills`, `dispels`, `levelPassives`, `talents`, `iconByEntryId`, `specInfoByClassSpec`, `_meta` | Layout ------ @@ -137,7 +140,8 @@ CoaExporter/ ├── Catalogs/ (game-data dumps for the wiki/calc) │ ├── Common.lua (one entry-walk; fans out to collectors) │ ├── Skills.lua -│ └── Talents.lua +│ ├── Talents.lua +│ └── Icons.lua (entry.Icon + C_ClassInfo.GetSpecInfo) ├── UI/ExportFrame.lua └── Core.lua (slash router) ```