Add /coae items scanner (force item-template fetch + capture)

New Collectors/Items.lua mirrors the MysticScrolls async scanner: calls
GetItemInfo()/SetHyperlink() over an id list/range so the client fetches
the templates from the server. This both populates itemcache.wdb (for the
server-side re-import) and captures name/quality/ilvl/slot/icon/spell/
tooltip into the CoaExporterItemCache SavedVariable for direct export.

Commands:
  /coae items scan            scan the CoA custom block (2000000-2000099 + 2089618)
  /coae items scan missing    scan the known-missing id list
  /coae items scan <from> <to>  scan an explicit inclusive range
  /coae items scan <id>       single id
  /coae items export|reset|status

Motivation: CoA per-class character-creation gear (ids 2000000+) is only
cached once a character has viewed it, so unseen items are absent from
both the client cache and db.exil.es. This scans them in without needing
to roll one character per class.
This commit is contained in:
Florian Andrew George Berthold 2026-06-06 21:18:55 +02:00
parent 7e5c58d1ca
commit d0999ebda3
3 changed files with 288 additions and 2 deletions

View file

@ -3,7 +3,7 @@
## Notes: Per-character export (talents/gear/mystic enchants/scrolls) + game-data catalog dump (skills/dispels/passives/talents) for db.exil.es ## Notes: Per-character export (talents/gear/mystic enchants/scrolls) + game-data catalog dump (skills/dispels/passives/talents) for db.exil.es
## Author: Subd from CoA / Exiles EU ## Author: Subd from CoA / Exiles EU
## Version: 1.0.0 ## Version: 1.0.0
## SavedVariables: CoaExporterSaved, CoaExporterConfig, CoaExporterScrollCache, CoaExporterCatalog ## SavedVariables: CoaExporterSaved, CoaExporterConfig, CoaExporterScrollCache, CoaExporterCatalog, CoaExporterItemCache
Util\Json.lua Util\Json.lua
Data\ScrollCatalog.lua Data\ScrollCatalog.lua
@ -14,6 +14,7 @@ Collectors\Gear.lua
Collectors\Enchants.lua Collectors\Enchants.lua
Collectors\MysticScrolls.lua Collectors\MysticScrolls.lua
Collectors\MysticScrollProbe.lua Collectors\MysticScrollProbe.lua
Collectors\Items.lua
Catalogs\Common.lua Catalogs\Common.lua
Catalogs\Skills.lua Catalogs\Skills.lua

View file

@ -0,0 +1,231 @@
-- CoaExporter / Collectors / Items.lua
--
-- Async item-ID scanner. Forces the client to fetch item templates from the
-- server for a list/range of item IDs, captures the resolved data, and (as a
-- side effect) populates the client's itemcache.wdb so a server-side
-- re-import can pick the items up too.
--
-- Why this exists: CoA's per-class character-creation starting gear lives at
-- item ids 2000000+. The client only caches an item once a character has
-- actually seen it in-game, so items nobody has viewed are absent from both
-- the cache and db.exil.es. Calling GetItemInfo()/SetHyperlink() on an
-- unseen id triggers ITEM_QUERY_SINGLE to the server, which fills the cache.
--
-- Usage:
-- /coae items scan → scan the default CoA custom block
-- /coae items scan missing → scan the known-missing id list (below)
-- /coae items scan 2000000 2000200 → scan an explicit inclusive range
-- /coae items scan 2089618 → scan a single id
-- /coae items export → JSON of everything resolved so far
-- /coae items status → resolved/seen counts
-- /coae items reset → clear the cache
--
-- Design mirrors Collectors/MysticScrolls.lua: a pass fires a batch of
-- GetItemInfo requests, waits ~5s, then re-scans and records whatever
-- resolved; unresolved ids are retried up to maxAttempts. Results accumulate
-- in the CoaExporterItemCache SavedVariable across sessions.
CoaExporter = _G.CoaExporter or {}
_G.CoaExporter = CoaExporter
local AE = CoaExporter
CoaExporterItemCache = CoaExporterItemCache or {
entries = {},
meta = { lastScanAt = nil, lastRange = nil, resolved = 0, unresolved = {} },
}
-- Default scan target: the CoA character-creation block plus the one known
-- outlier (2089618). Range is inclusive. Keep this aligned with the gaps the
-- db.exil.es item_character_creation mapping is still missing.
AE.ItemsDefaultFrom = 2000000
AE.ItemsDefaultTo = 2000099
AE.ItemsExtras = { 2089618 }
-- Known-missing ids (absent from db.exil.es as of 2026-06-06). `/coae items
-- scan missing` targets exactly these.
AE.ItemsMissing = {
2000003, 2000006,
2000027, 2000028, 2000029, 2000030,
2000034, 2000035, 2000036, 2000037,
2000048, 2000049, 2000050,
2089618,
}
local function EnsureScanner()
if not AE._itemScanner then
AE._itemScanner = CreateFrame("GameTooltip", "CoaExpItemsTT", nil, "GameTooltipTemplate")
AE._itemScanner:SetOwner(WorldFrame, "ANCHOR_NONE")
end
return AE._itemScanner
end
local function TipLines(tt)
local out = {}
for i = 1, tt:NumLines() do
local left = _G[tt:GetName() .. "TextLeft" .. i]
if left then
local t = left:GetText()
if t and t ~= "" then out[#out+1] = t end
end
end
return out
end
local function ScanItemTooltip(itemID)
local tt = EnsureScanner()
tt:ClearLines()
tt:SetHyperlink("item:" .. itemID)
return TipLines(tt)
end
-- Returns true when the item is resolved (now or already cached). On a miss
-- it pokes the server (tooltip hyperlink) so the next pass can pick it up.
local function TryResolve(itemID)
local cache = CoaExporterItemCache.entries
if cache[itemID] and cache[itemID].name then
return true
end
local name, link, quality, iLevel, reqLevel, class, subclass, maxStack,
equipSlot, texture, sellPrice = GetItemInfo(itemID)
if not name then
-- Trigger the server-side ITEM_QUERY_SINGLE; this is also what writes
-- the row into itemcache.wdb.
GameTooltip:SetOwner(WorldFrame, "ANCHOR_NONE")
GameTooltip:SetHyperlink("item:" .. itemID)
GameTooltip:Hide()
return false
end
local itemLines = ScanItemTooltip(itemID)
local spellName, spellRank
if GetItemSpell then
spellName, spellRank = GetItemSpell(itemID)
end
cache[itemID] = {
itemID = itemID,
name = name,
link = link,
quality = quality,
itemLevel = iLevel,
reqLevel = reqLevel,
class = class, -- localized item class ("Armor", "Weapon")
subClass = subclass, -- localized subclass ("Cloth", "Polearm")
maxStack = maxStack,
equipSlot = equipSlot, -- INVTYPE_* string
icon = texture and ((texture:match("[Ii]nterface\\[Ii]cons\\(.+)") or texture):lower()) or nil,
sellPrice = sellPrice, -- nil on stock 3.3.5; present on Ascension's client
spellName = spellName,
spellRank = spellRank,
tooltip = table.concat(itemLines, "\n"),
fetchedAt = time(),
}
return true
end
-- One synchronous pass over `ids`. Returns counts + the still-unresolved list.
function AE.ItemsScanPass(ids)
local total = #ids
local newlyResolved = 0
local unresolved = {}
for _, id in ipairs(ids) do
if TryResolve(id) then
newlyResolved = newlyResolved + 1
else
unresolved[#unresolved+1] = id
end
end
CoaExporterItemCache.meta.lastScanAt = time()
CoaExporterItemCache.meta.resolved = newlyResolved
CoaExporterItemCache.meta.unresolved = unresolved
return { total = total, resolved = newlyResolved, unresolved = #unresolved }
end
-- Multi-pass scan with delays so the server has time to answer queries.
function AE.ItemsStartScan(ids, callback)
local attempts = 0
local maxAttempts = 5
local function step()
attempts = attempts + 1
local stats = AE.ItemsScanPass(ids)
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"CoaExporter items: pass %d/%d - resolved this pass %d/%d (still unresolved: %d)",
attempts, maxAttempts, stats.resolved, stats.total, stats.unresolved))
if stats.unresolved > 0 and attempts < maxAttempts then
local t = CreateFrame("Frame")
local elapsed = 0
t:SetScript("OnUpdate", function(self, dt)
elapsed = elapsed + dt
if elapsed > 5 then
self:SetScript("OnUpdate", nil)
step()
end
end)
else
if stats.unresolved > 0 then
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"CoaExporter items: %d id(s) never resolved - likely no server-side template (dead ids).",
stats.unresolved))
end
if callback then callback(stats) end
end
end
step()
end
-- Build the id list for a `/coae items scan <args>` invocation.
function AE.ItemsResolveTargets(arg)
arg = arg and arg:match("^%s*(.-)%s*$") or ""
if arg == "" then
local ids = {}
for id = AE.ItemsDefaultFrom, AE.ItemsDefaultTo do ids[#ids+1] = id end
for _, id in ipairs(AE.ItemsExtras) do ids[#ids+1] = id end
return ids, string.format("%d-%d+extras", AE.ItemsDefaultFrom, AE.ItemsDefaultTo)
end
if arg == "missing" then
local ids = {}
for _, id in ipairs(AE.ItemsMissing) do ids[#ids+1] = id end
return ids, "missing"
end
local from, to = arg:match("^(%d+)%s+(%d+)$")
if from then
from, to = tonumber(from), tonumber(to)
if to < from then from, to = to, from end
-- Guard against a runaway range.
if (to - from) > 20000 then to = from + 20000 end
local ids = {}
for id = from, to do ids[#ids+1] = id end
return ids, string.format("%d-%d", from, to)
end
local single = arg:match("^(%d+)$")
if single then
return { tonumber(single) }, single
end
return nil, nil
end
function AE.ItemsExport()
local out = {
schemaVersion = 1,
exportedAt = date("!%Y-%m-%dT%H:%M:%SZ"),
cacheMeta = CoaExporterItemCache.meta,
entries = {},
}
for _, rec in pairs(CoaExporterItemCache.entries) do
table.insert(out.entries, rec)
end
return out
end
function AE.ItemsReset()
CoaExporterItemCache = {
entries = {},
meta = { lastScanAt = nil, lastRange = nil, resolved = 0, unresolved = {} },
}
end
function AE.ItemsCount()
local n = 0
for _ in pairs(CoaExporterItemCache.entries) do n = n + 1 end
return n
end
AE._loadedItems = true

View file

@ -381,6 +381,8 @@ CoaExporter commands:
/coae catalog all|skills|talents|icons /coae catalog all|skills|talents|icons
/coae catalog dispels [class]|passives [class]|status /coae catalog dispels [class]|passives [class]|status
/coae scrolls scan|export|reset|status /coae scrolls scan|export|reset|status
/coae items scan [missing | <from> <to> | <id>]
/coae items export|reset|status
/coae sv on|off (SavedVariables for character export) /coae sv on|off (SavedVariables for character export)
/coae debug /coae debug
/coae help /coae help
@ -481,6 +483,55 @@ local function HandleScrolls(rest)
end end
end end
local function HandleItems(rest)
rest = rest or ""
local sub, arg = rest:match("^(%S*)%s*(.*)$")
sub = (sub or ""):lower()
if sub == "" or sub == "scan" then
if not AE.ItemsStartScan then
DEFAULT_CHAT_FRAME:AddMessage("CoaExporter: items collector not loaded")
return
end
local ids, label = AE.ItemsResolveTargets(arg)
if not ids then
DEFAULT_CHAT_FRAME:AddMessage("CoaExporter: /coae items scan [missing | <from> <to> | <id>]")
return
end
CoaExporterItemCache.meta.lastRange = label
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"CoaExporter items: scanning %d id(s) [%s], up to ~25s...", #ids, label))
AE.ItemsStartScan(ids, function(stats)
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"CoaExporter items: scan complete - %d cached total, %d unresolved this run. "
.. "Log out to flush itemcache.wdb, then '/coae items export'.",
AE.ItemsCount and AE.ItemsCount() or 0, stats.unresolved))
end)
elseif sub == "export" then
if not AE.ItemsExport then
DEFAULT_CHAT_FRAME:AddMessage("CoaExporter: items collector not loaded")
return
end
local encoder = _G.CoaExporter_Json_Encode
if not encoder then
DEFAULT_CHAT_FRAME:AddMessage("CoaExporter: JSON encoder missing")
return
end
AE:ShowExport(encoder(AE.ItemsExport()), "CoaExporter - Items (Ctrl+C)")
elseif sub == "reset" then
if AE.ItemsReset then AE.ItemsReset() end
DEFAULT_CHAT_FRAME:AddMessage("CoaExporter items: cache cleared")
elseif sub == "status" then
local meta = CoaExporterItemCache and CoaExporterItemCache.meta or {}
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"CoaExporter items: %d cached (last range: %s, last scan: %s)",
AE.ItemsCount and AE.ItemsCount() or 0,
tostring(meta.lastRange or "-"),
meta.lastScanAt and date("%Y-%m-%d %H:%M:%S", meta.lastScanAt) or "never"))
else
DEFAULT_CHAT_FRAME:AddMessage("CoaExporter: /coae items [scan|export|reset|status]")
end
end
local function HandleDebug() local function HandleDebug()
local lines = {} local lines = {}
local function add(m) table.insert(lines, m) end local function add(m) table.insert(lines, m) end
@ -489,10 +540,11 @@ local function HandleDebug()
add(string.format("- AddOn: %s", tostring(ADDON_NAME))) add(string.format("- AddOn: %s", tostring(ADDON_NAME)))
add(string.format("- UI: %s", type(_G.CoaExporter_ShowExportFrame) == "function" and "yes" or "no")) add(string.format("- UI: %s", type(_G.CoaExporter_ShowExportFrame) == "function" and "yes" or "no"))
add(string.format("- JSON: %s", type(_G.CoaExporter_Json_Encode) == "function" and "yes" or "no")) add(string.format("- JSON: %s", type(_G.CoaExporter_Json_Encode) == "function" and "yes" or "no"))
add(string.format("- Loaded: talents=%s spellbook=%s gear=%s enchants=%s scrolls=%s probe=%s catCommon=%s catSkills=%s catTalents=%s", add(string.format("- Loaded: talents=%s spellbook=%s gear=%s enchants=%s scrolls=%s probe=%s items=%s catCommon=%s catSkills=%s catTalents=%s",
tostring(AE._loadedTalents or false), tostring(AE._loadedSpellbook or false), tostring(AE._loadedTalents or false), tostring(AE._loadedSpellbook or false),
tostring(AE._loadedGear or false), tostring(AE._loadedEnchants or false), tostring(AE._loadedGear or false), tostring(AE._loadedEnchants or false),
tostring(AE._loadedMysticScrolls or false), tostring(AE._loadedMysticScrollProbe or false), tostring(AE._loadedMysticScrolls or false), tostring(AE._loadedMysticScrollProbe or false),
tostring(AE._loadedItems or false),
tostring(AE._loadedCatalogCommon or false), tostring(AE._loadedCatalogSkills or false), tostring(AE._loadedCatalogCommon or false), tostring(AE._loadedCatalogSkills or false),
tostring(AE._loadedCatalogTalents or false))) tostring(AE._loadedCatalogTalents or false)))
@ -523,6 +575,7 @@ local function HandleDebug()
add("MysticEnchants: MISSING") add("MysticEnchants: MISSING")
end end
add(string.format("ScrollCatalog: %d entries", AE.ScrollCatalogTotal or 0)) add(string.format("ScrollCatalog: %d entries", AE.ScrollCatalogTotal or 0))
add(string.format("Items: %d cached", AE.ItemsCount and AE.ItemsCount() or 0))
local meta = (CoaExporterCatalog and CoaExporterCatalog._meta) or {} local meta = (CoaExporterCatalog and CoaExporterCatalog._meta) or {}
add(string.format("Catalog: lastScan=%s filter=%s", add(string.format("Catalog: lastScan=%s filter=%s",
@ -553,6 +606,7 @@ local function Dispatch(msg)
if cmd == "export" then return HandleExport(rest) end if cmd == "export" then return HandleExport(rest) end
if cmd == "catalog" then return HandleCatalog(rest) end if cmd == "catalog" then return HandleCatalog(rest) end
if cmd == "scrolls" then return HandleScrolls(rest) end if cmd == "scrolls" then return HandleScrolls(rest) end
if cmd == "items" then return HandleItems(rest) end
if cmd == "debug" then return HandleDebug() end if cmd == "debug" then return HandleDebug() end
if cmd == "sv" then return HandleSv(Norm(rest)) end if cmd == "sv" then return HandleSv(Norm(rest)) end
DEFAULT_CHAT_FRAME:AddMessage("CoaExporter: unknown command. Type /coae help") DEFAULT_CHAT_FRAME:AddMessage("CoaExporter: unknown command. Type /coae help")