import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import { fileURLToPath } from "node:url"; import { decodeJsonBytes, resolveBaselinePath } from "./baseline.mjs"; import { archiveRowsToDbc } from "./archive-dbc.mjs"; const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); const defaultProjectRoot = path.resolve(scriptDirectory, "..", ".."); const baseSpellRelativePath = "client-current/patch-T/DBFilesClient/Spell.dbc"; const overlaySpellRelativePath = "client-current/area-52/patch-D/DBFilesClient/Spell.dbc"; const spellSourceId = "global-spell-base"; const classAliases = new Map([ [14, ["DemonHunter"]], [19, ["Monk"]], [20, ["SonOfArugal"]], ]); function normalizeClassName(value) { return String(value ?? "").toLocaleLowerCase("en-US").replace(/[^a-z0-9]/g, ""); } function gameIdForClassName(value) { return String(value ?? "").toLocaleLowerCase("en-US") .replace(/['’]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); } function sha256(buffer) { return crypto.createHash("sha256").update(buffer).digest("hex"); } function pathExists(directory) { try { return fs.statSync(directory).isDirectory(); } catch { return false; } } function rootsForDrive(projectRoot, drive) { const parent = path.resolve(projectRoot, ".."); if (drive === "H") { return { clientRoot: "H:\\resources\\client", extractedRoot: "H:\\LadiksMPQEditor", }; } return { clientRoot: path.join(parent, "resources", "client"), extractedRoot: path.join(parent, "LadiksMPQEditor"), }; } /** Resolve a client and extraction directory as one source set. */ export function resolveSourceRoots({ projectRoot = defaultProjectRoot, clientRoot, extractedRoot, } = {}) { const resolvedProjectRoot = path.resolve(projectRoot); const hasClient = clientRoot !== undefined; const hasExtracted = extractedRoot !== undefined; if (hasClient !== hasExtracted) { throw new Error("Pass clientRoot and extractedRoot together so the source directories stay paired"); } if (clientRoot !== undefined && extractedRoot !== undefined) { const roots = { clientRoot: path.resolve(clientRoot), extractedRoot: path.resolve(extractedRoot) }; if (!pathExists(roots.clientRoot)) throw new Error(`Client source root does not exist: ${roots.clientRoot}`); if (!pathExists(roots.extractedRoot)) throw new Error(`Extracted source root does not exist: ${roots.extractedRoot}`); return { projectRoot: resolvedProjectRoot, ...roots, rootSet: "supplied" }; } const hRoots = rootsForDrive(resolvedProjectRoot, "H"); if (pathExists(hRoots.clientRoot) && pathExists(hRoots.extractedRoot)) { return { projectRoot: resolvedProjectRoot, ...hRoots, rootSet: "H" }; } const siblingRoots = rootsForDrive(resolvedProjectRoot, "F"); if (pathExists(siblingRoots.clientRoot) && pathExists(siblingRoots.extractedRoot)) { return { projectRoot: resolvedProjectRoot, ...siblingRoots, rootSet: "F" }; } throw new Error("Could not find a complete local client/extraction root pair; pass both clientRoot and extractedRoot"); } function assert(condition, message) { if (!condition) throw new Error(message); } function readJson(buffer, displayPath) { let parsed; try { parsed = JSON.parse(decodeJsonBytes(buffer, displayPath).toString("utf8")); } catch (error) { throw new Error(`${displayPath} is malformed JSON: ${error.message}`); } return parsed; } function inspectDbc(buffer, displayPath, minimumFields = 1) { assert(buffer.length >= 20, `${displayPath} has a truncated WDBC header`); assert(buffer.toString("ascii", 0, 4) === "WDBC", `${displayPath} is not a WDBC file`); const recordCount = buffer.readUInt32LE(4); const fieldCount = buffer.readUInt32LE(8); const recordSize = buffer.readUInt32LE(12); const stringSize = buffer.readUInt32LE(16); assert(fieldCount >= minimumFields, `${displayPath} has ${fieldCount} fields; expected at least ${minimumFields}`); assert(recordSize >= fieldCount * 4, `${displayPath} record size is smaller than its field count`); const expectedLength = 20 + recordCount * recordSize + stringSize; assert(Number.isSafeInteger(expectedLength) && expectedLength === buffer.length, `${displayPath} has inconsistent WDBC record/string lengths`); return { recordCount, fieldCount, recordSize, stringSize, stringsOffset: 20 + recordCount * recordSize }; } function field(buffer, header, rowBase, index) { assert(index >= 0 && index < header.fieldCount, `DBC field ${index} is outside the declared schema`); return buffer.readUInt32LE(rowBase + index * 4); } function signedField(buffer, header, rowBase, index) { assert(index >= 0 && index < header.fieldCount, `DBC field ${index} is outside the declared schema`); return buffer.readInt32LE(rowBase + index * 4); } function floatField(buffer, header, rowBase, index) { assert(index >= 0 && index < header.fieldCount, `DBC field ${index} is outside the declared schema`); return buffer.readFloatLE(rowBase + index * 4); } function stringAt(buffer, header, offset) { if (!offset || offset >= header.stringSize) return ""; const start = header.stringsOffset + offset; const end = buffer.indexOf(0, start); return buffer.toString("utf8", start, end < 0 ? buffer.length : end); } function localizedString(buffer, header, rowBase, firstField, localeCount = 16) { for (let locale = 0; locale < localeCount; locale += 1) { const value = stringAt(buffer, header, field(buffer, header, rowBase, firstField + locale)); if (value) return value; } return ""; } function cleanClientText(value) { return String(value ?? "") .replace(/\|c[0-9a-f]{8}/gi, "") .replace(/\|r/gi, "") .replace(/[\r\n]+/g, " ") .replace(/\s+/g, " ") .trim(); } function tableMap(buffer, header, valueField, valueKind) { const values = new Map(); for (let row = 0; row < header.recordCount; row += 1) { const base = 20 + row * header.recordSize; const id = field(buffer, header, base, 0); const value = valueKind === "float" ? floatField(buffer, header, base, valueField) : signedField(buffer, header, base, valueField); if (!values.has(id)) values.set(id, value); } return values; } function rangeTable(buffer, header) { const values = new Map(); for (let row = 0; row < header.recordCount; row += 1) { const base = 20 + row * header.recordSize; const id = field(buffer, header, base, 0); values.set(id, { min: Math.max(0, floatField(buffer, header, base, 1), floatField(buffer, header, base, 2)), max: Math.max(0, floatField(buffer, header, base, 3), floatField(buffer, header, base, 4)), }); } return values; } function collectClassMaps(buffer, header, classes) { const namesById = new Map(); const idsByName = new Map(); const rows = []; for (let index = 0; index < header.recordCount; index += 1) { const base = 20 + index * header.recordSize; const id = field(buffer, header, base, 0); const name = localizedString(buffer, header, base, 4); if (!name) continue; namesById.set(id, name); const normalized = normalizeClassName(name); const matches = idsByName.get(normalized) ?? []; matches.push(id); idsByName.set(normalized, matches); if (classes.some((definition) => normalizeClassName(definition.className) === normalized)) { rows.push({ id, name, fields: Array.from({ length: header.fieldCount }, (_, fieldIndex) => field(buffer, header, base, fieldIndex)) }); } } return { namesById, idsByName, rows }; } function allStringsInRow(buffer, header, rowBase) { const strings = {}; for (let index = 0; index < header.fieldCount; index += 1) { const offset = field(buffer, header, rowBase, index); const value = stringAt(buffer, header, offset); if (value) strings[index] = value; } return strings; } function collectSpecEvidence(buffer, header, wantedNames) { const rows = []; for (let index = 0; index < header.recordCount; index += 1) { const base = 20 + index * header.recordSize; const strings = allStringsInRow(buffer, header, base); const matches = [...wantedNames].filter((name) => Object.values(strings).some((value) => normalizeClassName(value) === normalizeClassName(name))); if (matches.length === 0) continue; rows.push({ id: field(buffer, header, base, 0), classNamesMatched: matches, fields: Array.from({ length: header.fieldCount }, (_, fieldIndex) => field(buffer, header, base, fieldIndex)), strings, }); } return rows; } function buildSkillLineIndex(buffer, header) { const bySpellId = new Map(); for (let row = 0; row < header.recordCount; row += 1) { const base = 20 + row * header.recordSize; const skillLineId = field(buffer, header, base, 1); const spellId = field(buffer, header, base, 2); const classMask = field(buffer, header, base, 5); const entries = bySpellId.get(spellId) ?? []; if (!entries.some((entry) => entry.skillLineId === skillLineId && entry.classMask === classMask)) { entries.push({ skillLineId, classMask }); } bySpellId.set(spellId, entries); } return bySpellId; } function classIdsFromMask(mask) { const ids = []; if (mask === 0 || mask === 0xffffffff) return ids; for (let classId = 1; classId <= 32; classId += 1) { if ((mask & (1 << (classId - 1))) !== 0) ids.push(classId); } return ids; } function makeSpellDbc(buffer, header, id, supports) { const base = header.idToBase.get(id); if (base === undefined) return null; const unsigned = (index) => field(buffer, header, base, index); const signed = (index) => signedField(buffer, header, base, index); const floating = (index) => floatField(buffer, header, base, index); const localized = (index) => cleanClientText(localizedString(buffer, header, base, index)); const durationIndex = unsigned(40); const castTimeIndex = unsigned(28); const rangeIndex = unsigned(46); const effects = Array.from({ length: 3 }, (_, effectIndex) => ({ index: effectIndex, effectType: unsigned(71 + effectIndex), auraType: unsigned(95 + effectIndex), basePoints: signed(80 + effectIndex) + 1, dieSides: signed(74 + effectIndex), pointsPerLevel: floating(77 + effectIndex), mechanic: unsigned(83 + effectIndex), implicitTargetA: unsigned(86 + effectIndex), implicitTargetB: unsigned(89 + effectIndex), radiusIndex: unsigned(92 + effectIndex), radius: lookupSupport(supports.radii, unsigned(92 + effectIndex)), periodMs: unsigned(98 + effectIndex), valueMultiplier: floating(101 + effectIndex), chainTargets: unsigned(104 + effectIndex), itemType: unsigned(107 + effectIndex), miscValue: signed(110 + effectIndex), miscValueB: signed(113 + effectIndex), triggerSpellId: unsigned(116 + effectIndex), pointsPerCombo: floating(119 + effectIndex), coefficient: floating(229 + effectIndex), })).filter((effect) => ( effect.effectType || effect.auraType || effect.basePoints !== 1 || effect.triggerSpellId )); return { id, name: localized(136), rankText: localized(153), description: localized(170) || localized(187), categoryId: unsigned(1), dispelType: unsigned(2), mechanic: unsigned(3), attributes: Array.from({ length: 8 }, (_, attribute) => unsigned(4 + attribute)), castTimeIndex, castTimeMs: lookupSupport(supports.castTimes, castTimeIndex), recoveryTimeMs: unsigned(29), categoryRecoveryTimeMs: unsigned(30), interruptFlags: unsigned(31), auraInterruptFlags: unsigned(32), channelInterruptFlags: unsigned(33), procFlags: unsigned(34), procChance: unsigned(35), procCharges: unsigned(36), maxLevel: unsigned(37), baseLevel: unsigned(38), spellLevel: unsigned(39), durationIndex, durationMs: lookupSupport(supports.durations, durationIndex), powerType: signed(41), powerCost: unsigned(42), powerCostPerLevel: unsigned(43), powerCostPerSecond: unsigned(44), powerCostPerSecondPerLevel: unsigned(45), rangeIndex, rangeMin: supports.ranges ? supports.ranges.get(rangeIndex)?.min ?? (rangeIndex === 0 ? 0 : null) : (rangeIndex === 0 ? 0 : null), rangeMax: supports.ranges ? supports.ranges.get(rangeIndex)?.max ?? (rangeIndex === 0 ? 0 : null) : (rangeIndex === 0 ? 0 : null), projectileSpeed: floating(47), stackAmount: unsigned(49), equippedItemClass: signed(67), equippedItemSubclassMask: signed(68), equippedItemInventoryMask: signed(69), iconId: unsigned(133), powerCostPercentage: unsigned(204), startRecoveryCategory: unsigned(205), startRecoveryTimeMs: unsigned(206), maximumTargets: unsigned(212), damageClass: unsigned(213), preventionType: unsigned(214), schoolMask: unsigned(225), runeCostId: unsigned(226), powerDisplayId: unsigned(228), effects, }; } function lookupSupport(table, id) { if (id === 0) return 0; if (!table) return null; return table.get(id) ?? null; } function addDiagnostic(diagnostics, diagnostic, keys) { const key = keys ? keys(diagnostic) : `${diagnostic.code}:${diagnostic.message}`; if (diagnostics._keys.has(key)) return; diagnostics._keys.add(key); diagnostics.push(diagnostic); } function evidenceKey(classId, row) { return `coa-${classId}-cad-${Number(row.ID)}`; } function parseSpellRows(buffer, header, ids, supports, diagnostics, archive = null) { const idToBase = new Map(); const duplicateIds = new Set(); for (let index = 0; index < header.recordCount; index += 1) { const base = 20 + index * header.recordSize; const id = field(buffer, header, base, 0); if (idToBase.has(id)) duplicateIds.add(id); else idToBase.set(id, base); } header.idToBase = idToBase; for (const id of duplicateIds) { addDiagnostic(diagnostics, { code: "duplicate-spell-id", severity: "warning", message: `Base Spell.dbc contains duplicate spell ID ${id}; the first row was retained.`, spellId: id, }, (item) => `${item.code}:${item.spellId}`); } const records = new Map(); const archivedIds = new Set(); let archivedBuffer; let archivedHeader; if (archive) { archivedBuffer = archiveRowsToDbc(archive.records, archive.fieldTypes); archivedHeader = inspectDbc(archivedBuffer, "pinned August global Spell rows", 234); archivedHeader.idToBase = new Map(archive.records.map((record, index) => [record.f0, 20 + index * archivedHeader.recordSize])); } const triggerEvidenceBySpellId = new Map(); const pending = [...ids].sort((left, right) => left - right); const queued = new Set(pending); while (pending.length) { const id = pending.shift(); if (records.has(id)) continue; let dbc = makeSpellDbc(buffer, header, id, supports); if (!dbc && archivedHeader) { dbc = makeSpellDbc(archivedBuffer, archivedHeader, id, supports); if (dbc) archivedIds.add(id); } if (!dbc) { records.set(id, null); continue; } records.set(id, dbc); for (const effect of dbc.effects) { const triggerSpellId = Number(effect.triggerSpellId) || (effect.effectType === 168 ? Number(effect.miscValue) : 0); if (!triggerSpellId) continue; const references = triggerEvidenceBySpellId.get(triggerSpellId) ?? []; references.push({ kind: "dbc-effect-trigger", sourceSpellId: id, effectIndex: effect.index }); triggerEvidenceBySpellId.set(triggerSpellId, references); if (queued.has(triggerSpellId)) continue; queued.add(triggerSpellId); pending.push(triggerSpellId); } } return { records, triggerEvidenceBySpellId, archivedIds }; } function makeSourceRecord({ id, portablePath, kind, role, buffer, header }) { return { id, path: portablePath, sha256: sha256(buffer), bytes: buffer.length, kind, role, ...(header ? { recordCount: header.recordCount, fieldCount: header.fieldCount, recordSize: header.recordSize, stringSize: header.stringSize, } : {}), }; } function addFileSource(sources, id, absolutePath, portablePath, kind, role, required = false, minFields = 1) { if (!fs.existsSync(absolutePath)) { if (required) throw new Error(`Required source file is missing: ${portablePath}`); return null; } const buffer = fs.readFileSync(absolutePath); let header = null; if (kind === "dbc") header = inspectDbc(buffer, portablePath, minFields); const source = makeSourceRecord({ id, portablePath, kind, role, buffer, header }); sources.push(source); return { source, buffer, header }; } function buildClassCatalogs(classes, cadRows, classDbc, specEvidence, diagnostics) { const byName = new Map(); for (const row of classDbc.rows) { const normalized = normalizeClassName(row.name); const matches = byName.get(normalized) ?? []; matches.push(row); byName.set(normalized, matches); } const catalogs = []; const classIdByNormalizedName = new Map(); for (const definition of classes) { const normalized = normalizeClassName(definition.className); const candidates = byName.get(normalized) ?? []; definition.gameId = gameIdForClassName(definition.className); const aliases = classAliases.get(Number(definition.classId)) ?? []; if (candidates.length === 1) { definition.clientClassId = candidates[0].id; classIdByNormalizedName.set(normalized, Number(definition.classId)); } else { definition.clientClassId = null; addDiagnostic(diagnostics, { code: candidates.length ? "ambiguous-game-class-id" : "missing-game-class-id", severity: "warning", message: candidates.length ? `Class ${definition.className} matches multiple ChrClasses rows.` : `Class ${definition.className} has no matching ChrClasses row.`, classId: Number(definition.classId), candidateGameIds: candidates.map((candidate) => candidate.id), }, (item) => `${item.code}:${item.classId}`); } if (aliases.length) definition.aliases = aliases; catalogs.push({ classId: Number(definition.classId), className: definition.className, gameId: definition.gameId, clientClassId: definition.clientClassId, ...(aliases.length ? { aliases } : {}), advancementRows: [], chrClassEvidence: candidates.length === 1 ? candidates[0] : null, chrSpecEvidence: specEvidence.filter((row) => row.classNamesMatched.some((name) => ( normalizeClassName(name) === normalized || aliases.some((alias) => normalizeClassName(alias) === normalizeClassName(name)) ))), }); for (const alias of aliases) classIdByNormalizedName.set(normalizeClassName(alias), Number(definition.classId)); } const catalogByClassId = new Map(catalogs.map((catalog) => [catalog.classId, catalog])); const filteredCadRows = []; for (const row of cadRows) { const classId = classIdByNormalizedName.get(normalizeClassName(row.Class)); if (classId === undefined) continue; const catalog = catalogByClassId.get(classId); catalog.advancementRows.push(row); filteredCadRows.push({ classId, row }); } return { catalogs, filteredCadRows, classIdByNormalizedName }; } function buildAbilities({ filteredCadRows, nodes, spellRankData, skillLineBySpellId, diagnostics }) { const ownersBySpellId = new Map(); for (const { classId, row } of filteredCadRows) { for (const rawSpellId of Array.isArray(row.Spells) ? row.Spells : []) { const spellId = Number(rawSpellId); if (!Number.isInteger(spellId) || spellId <= 0) continue; const owners = ownersBySpellId.get(spellId) ?? new Set(); owners.add(classId); ownersBySpellId.set(spellId, owners); } } const rankRowsByFirstSpell = new Map(); const rankRowsBySpell = new Map(); for (const rankRow of spellRankData) { const spellId = Number(rankRow.spellId); const firstSpellId = Number(rankRow.firstSpellId); if (!Number.isInteger(spellId) || spellId <= 0 || !Number.isInteger(firstSpellId) || firstSpellId <= 0) continue; const family = rankRowsByFirstSpell.get(firstSpellId) ?? []; family.push(rankRow); rankRowsByFirstSpell.set(firstSpellId, family); rankRowsBySpell.set(spellId, rankRow); } const nodeByClassAndId = new Map(nodes.map((node) => [`${Number(node.classId)}:${Number(node.id)}`, node])); const abilities = []; for (const { classId, row } of filteredCadRows) { const key = evidenceKey(classId, row); const spellIds = [...new Set((Array.isArray(row.Spells) ? row.Spells : []).map(Number).filter((id) => Number.isInteger(id) && id > 0))].sort((a, b) => a - b); const node = nodeByClassAndId.get(`${classId}:${Number(row.ID)}`); const selectedRankSpellIds = new Set(); for (const spellId of spellIds) { const family = rankRowsByFirstSpell.get(spellId) ?? (rankRowsBySpell.has(spellId) ? [rankRowsBySpell.get(spellId)] : []); const candidates = [...new Map(family.map((candidate) => [Number(candidate.spellId), candidate])).values()]; const sameClass = candidates.filter((candidate) => { const candidateId = Number(candidate.spellId); const cadOwner = ownersBySpellId.get(candidateId)?.has(classId) ?? false; const maskOwner = (skillLineBySpellId.get(candidateId) ?? []).some((entry) => classIdsFromMask(entry.classMask).includes(classId)); return cadOwner || maskOwner || candidateId === spellId; }); const chosen = sameClass.length ? sameClass : candidates; for (const candidate of chosen) selectedRankSpellIds.add(Number(candidate.spellId)); if (candidates.length > 0 && sameClass.length === 0) { addDiagnostic(diagnostics, { code: "rank-identity-no-same-class-match", severity: "warning", message: `No same-class local rank identity was found for ${row.Name} in ${row.Class}; unscoped candidates are retained as evidence.`, classId, abilityKey: key, firstSpellId: spellId, candidateSpellIds: candidates.map((candidate) => Number(candidate.spellId)).sort((a, b) => a - b), }, (item) => `${item.code}:${item.abilityKey}:${item.firstSpellId}`); } const byRank = new Map(); for (const candidate of sameClass) { const rank = Number(candidate.rank) || null; if (rank === null) continue; const rankIds = byRank.get(rank) ?? []; rankIds.push(Number(candidate.spellId)); byRank.set(rank, rankIds); } for (const [rank, candidateSpellIds] of byRank) { if (candidateSpellIds.length > 1) { addDiagnostic(diagnostics, { code: "ambiguous-rank-join", severity: "warning", message: `Multiple same-class spell IDs match rank ${rank} of ${row.Name}.`, classId, abilityKey: key, firstSpellId: spellId, rank, candidateSpellIds: candidateSpellIds.sort((a, b) => a - b), }, (item) => `${item.code}:${item.abilityKey}:${item.firstSpellId}:${item.rank}`); } } } const evidence = [{ kind: "local-character-advancement-row", sourceId: "client-cad", rowId: Number(row.ID), rowClass: row.Class, }]; if (node) evidence.push({ kind: "july-node-baseline-id-match", sourceId: "july-coa-node-baseline", nodeId: Number(node.id), nodeName: node.name }); if (String(row.Realms ?? "")) evidence.push({ kind: "uninterpreted-realm-mask", value: String(row.Realms) }); abilities.push({ key, classId, name: String(row.Name ?? ""), spellIds, nodeIds: node ? [Number(node.id)] : [], rankSpellIds: [...selectedRankSpellIds].sort((a, b) => { const rankDiff = (Number(rankRowsBySpell.get(a)?.rank) || 0) - (Number(rankRowsBySpell.get(b)?.rank) || 0); return rankDiff || a - b; }), live: null, evidence, }); } return { abilities, ownersBySpellId, rankRowsBySpell, rankRowsByFirstSpell }; } function buildSpellEvidence({ nodes, extraNodes = [], baselineSpells = [], progressionChains, abilities, spellRankData, ownersBySpellId }) { const evidenceBySpell = new Map(); const classIdsBySpell = new Map(); const add = (spellId, evidence, classId) => { if (!Number.isInteger(spellId) || spellId <= 0) return; const evidenceItems = evidenceBySpell.get(spellId) ?? []; evidenceItems.push(evidence); evidenceBySpell.set(spellId, evidenceItems); if (Number.isInteger(classId)) { const classes = classIdsBySpell.get(spellId) ?? new Set(); classes.add(classId); classIdsBySpell.set(spellId, classes); } }; const nodeNameBySpell = new Map(); for (const node of nodes) { for (const rank of Array.isArray(node.rankDescriptions) ? node.rankDescriptions : []) { const spellId = Number(rank.spellId); add(spellId, { kind: "july-node-rank", sourceId: "july-coa-node-baseline", nodeId: Number(node.id), classId: Number(node.classId), rank: Number(rank.rank) }, Number(node.classId)); const names = nodeNameBySpell.get(spellId) ?? []; names.push({ name: node.name, description: rank.description, rank: rank.rank }); nodeNameBySpell.set(spellId, names); } } for (const node of extraNodes) { for (const rank of node.rankDescriptions ?? []) { add(Number(rank.spellId), { kind: "august-node-rank", sourceId: "august-voljin-supplement", nodeId: Number(node.id), classId: Number(node.classId), rank: Number(rank.rank) }, Number(node.classId)); } } for (const spell of baselineSpells) { const spellId = Number(spell?.id); const classIds = [...new Set((spell?.classIds ?? []).map(Number).filter((classId) => Number.isInteger(classId) && classId > 11))]; for (const classId of classIds) { add(spellId, { kind: "july-spell-identity", sourceId: "july-coa-node-baseline", classId, name: String(spell?.name ?? ""), rank: String(spell?.rank ?? ""), }, classId); } } for (const ability of abilities) { for (const spellId of ability.spellIds) { add(spellId, { kind: "local-cad-ability", sourceId: "client-cad", abilityKey: ability.key, classId: ability.classId }, ability.classId); } for (const spellId of ability.rankSpellIds) { add(spellId, { kind: "local-cad-rank-family", sourceId: "client-spell-ranks", abilityKey: ability.key, classId: ability.classId }, ability.classId); } } for (const chain of progressionChains) { for (const rank of Array.isArray(chain.ranks) ? chain.ranks : []) { add(Number(rank.spellId), { kind: "july-progression-chain-rank", sourceId: "july-coa-node-baseline", chainId: chain.id, classId: Number(chain.classId), rank: Number(rank.rank), }, Number(chain.classId)); } } const sourceById = new Map(); for (const rankRow of spellRankData) { const spellId = Number(rankRow.spellId); if (!Number.isInteger(spellId) || spellId <= 0) continue; const rows = sourceById.get(spellId) ?? []; rows.push(rankRow); sourceById.set(spellId, rows); } for (const [spellId, rows] of sourceById) { if (!evidenceBySpell.has(spellId)) continue; for (const row of rows) add(spellId, { kind: "local-spell-rank-data", sourceId: "client-spell-ranks", firstSpellId: Number(row.firstSpellId), rank: Number(row.rank), level: Number(row.level), }); } for (const [spellId, owners] of ownersBySpellId) { if (!classIdsBySpell.has(spellId)) classIdsBySpell.set(spellId, new Set()); for (const owner of owners) classIdsBySpell.get(spellId).add(owner); } return { evidenceBySpell, classIdsBySpell, nodeNameBySpell }; } function propagateTriggerClassOwnership(classIdsBySpell, evidenceBySpell, triggerEvidenceBySpellId) { const targetsBySource = new Map(); for (const [targetSpellId, references] of triggerEvidenceBySpellId) { for (const reference of references) { const sourceSpellId = Number(reference.sourceSpellId); const targets = targetsBySource.get(sourceSpellId) ?? new Set(); targets.add(targetSpellId); targetsBySource.set(sourceSpellId, targets); } } const queue = []; const queued = new Set(); const enqueue = (spellId, classId) => { const key = `${spellId}:${classId}`; if (queued.has(key)) return; queued.add(key); queue.push({ spellId, classId }); }; for (const [spellId, classIds] of classIdsBySpell) { for (const classId of classIds) enqueue(spellId, classId); } for (let cursor = 0; cursor < queue.length; cursor += 1) { const { spellId: sourceSpellId, classId } = queue[cursor]; for (const targetSpellId of targetsBySource.get(sourceSpellId) ?? []) { const targetClasses = classIdsBySpell.get(targetSpellId) ?? new Set(); if (!targetClasses.has(classId)) { targetClasses.add(classId); const evidenceItems = evidenceBySpell.get(targetSpellId) ?? []; evidenceItems.push({ kind: "trigger-payload-owner", sourceId: "dbc-effect-trigger", sourceSpellId, classId, }); evidenceBySpell.set(targetSpellId, evidenceItems); } classIdsBySpell.set(targetSpellId, targetClasses); enqueue(targetSpellId, classId); } } } function makeSpellRecords({ spellRecords, evidenceBySpell, classIdsBySpell, nodeNameBySpell, rankRowsBySpell, diagnostics, overlayContains, archivedIds, archiveSource }) { const spellIds = new Set([...evidenceBySpell.keys(), ...spellRecords.keys()]); const spells = []; for (const id of [...spellIds].sort((left, right) => left - right)) { const dbc = spellRecords.get(id) ?? null; const evidence = evidenceBySpell.get(id) ?? []; const nodeName = nodeNameBySpell.get(id)?.find((item) => item.name)?.name ?? ""; const classRow = evidence.find((item) => item.kind === "local-cad-ability"); const rankRow = rankRowsBySpell.get(id); const classCatalogName = classRow ? classRow.abilityKey : ""; const cadAbility = classCatalogName ? classCatalogName : null; const name = dbc?.name || nodeName || ""; const rank = dbc?.rankText || (rankRow?.rank ? `Rank ${rankRow.rank}` : ""); const nodeDescription = nodeNameBySpell.get(id)?.find((item) => item.description)?.description ?? ""; if (!dbc) { addDiagnostic(diagnostics, { code: "missing-spell-reference", severity: "warning", message: overlayContains.has(id) ? `Spell ID ${id} is referenced by the selected local baseline but absent from BASE patch-T Spell.dbc; the comparative patch-D row was not used.` : `Spell ID ${id} is referenced by the selected local baseline but absent from BASE patch-T Spell.dbc.`, spellId: id, overlayOnly: overlayContains.has(id), references: evidence, }, (item) => `${item.code}:${item.spellId}`); } spells.push({ id, classIds: [...(classIdsBySpell.get(id) ?? [])].sort((a, b) => a - b), name, rank, description: nodeDescription || dbc?.description || "", source: archivedIds.has(id) ? archiveSource.path : "client-current/patch-T/DBFilesClient/Spell.dbc", sourceEvidence: [ ...evidence, ...(rankRow ? [{ kind: "local-spell-rank-data", firstSpellId: Number(rankRow.firstSpellId), rank: Number(rankRow.rank), level: Number(rankRow.level) }] : []), ...(dbc ? [{ kind: "base-spell-dbc-row", sourceId: archivedIds.has(id) ? archiveSource.id : spellSourceId }] : []), ...(!dbc && overlayContains.has(id) ? [{ kind: "comparative-overlay-presence-only", sourceId: "area52-spell-overlay" }] : []), ...(cadAbility ? [{ kind: "cad-ability-key", abilityKey: cadAbility }] : []), ], ...(dbc ? { dbc } : {}), }); } return spells; } function requiredJsonSource(sources, id, absolutePath, portablePath, role, shape) { const source = addFileSource(sources, id, absolutePath, portablePath, "json", role, true); const value = readJson(source.buffer, portablePath); if (shape === "array") assert(Array.isArray(value), `${portablePath} must contain a JSON array`); if (shape === "object") assert(value && typeof value === "object" && !Array.isArray(value), `${portablePath} must contain a JSON object`); return { source: source.source, value }; } /** Build an offline, source-labelled reference object from the July baseline and local client files. */ export function buildLocalReference(options = {}) { const roots = resolveSourceRoots(options); const sources = []; const diagnostics = []; diagnostics._keys = new Set(); const baselineRelativePath = resolveBaselinePath(roots.projectRoot); const baselinePath = path.join(roots.projectRoot, baselineRelativePath); const baselineSource = requiredJsonSource(sources, "july-coa-node-baseline", baselinePath, baselineRelativePath, "baseline", "object"); const baseline = baselineSource.value; assert(Array.isArray(baseline.classes), `${baselineRelativePath} has no classes array`); assert(Array.isArray(baseline.entries), `${baselineRelativePath} has no entries array`); assert(Array.isArray(baseline.progressionChains), `${baselineRelativePath} has no progressionChains array`); assert(baseline.spells === undefined || Array.isArray(baseline.spells), `${baselineRelativePath} spells must be an array when present`); assert(baseline.source && typeof baseline.source.synchronizedAt === "string", `${baselineRelativePath} has no fixed snapshot timestamp`); assert(Number.isFinite(Date.parse(baseline.source.synchronizedAt)), `${baselineRelativePath} has an invalid snapshot timestamp`); const cadPath = path.join(roots.clientRoot, "Data", "Content", "CharacterAdvancementData.json"); const rankPath = path.join(roots.clientRoot, "Data", "Content", "SpellRankData.json"); const cad = requiredJsonSource(sources, "client-cad", cadPath, "client/Data/Content/CharacterAdvancementData.json", "content", "array"); const rankData = requiredJsonSource(sources, "client-spell-ranks", rankPath, "client/Data/Content/SpellRankData.json", "content", "array"); for (const [index, row] of cad.value.entries()) { assert(row && typeof row === "object" && !Array.isArray(row), `CharacterAdvancementData.json row ${index} must be an object`); } for (const [index, row] of rankData.value.entries()) { assert(row && typeof row === "object" && !Array.isArray(row), `SpellRankData.json row ${index} must be an object`); } const baseSpellPath = path.join(roots.extractedRoot, ...baseSpellRelativePath.split("/")); const baseSpellSource = addFileSource(sources, spellSourceId, baseSpellPath, `extracts/${baseSpellRelativePath}`, "dbc", "base", true, 234); assert(baseSpellSource.header.fieldCount >= 234, "BASE patch-T Spell.dbc does not expose the expected 234-field schema"); const spellHeader = baseSpellSource.header; const classRoot = path.join(roots.extractedRoot, "client-current", "patch-M", "DBFilesClient"); const classDbcSource = addFileSource(sources, "global-chr-classes", path.join(classRoot, "ChrClasses.dbc"), "extracts/client-current/patch-M/DBFilesClient/ChrClasses.dbc", "dbc", "class-metadata", true, 20); const specDbcSource = addFileSource(sources, "global-chr-specs", path.join(classRoot, "ChrSpecs.dbc"), "extracts/client-current/patch-M/DBFilesClient/ChrSpecs.dbc", "dbc", "class-metadata", true, 10); const skillDbcSource = addFileSource(sources, "global-skill-line-ability", path.join(classRoot, "SkillLineAbility.dbc"), "extracts/client-current/patch-M/DBFilesClient/SkillLineAbility.dbc", "dbc", "class-ability-metadata", true, 6); const classDbc = collectClassMaps(classDbcSource.buffer, classDbcSource.header, baseline.classes); const specNames = new Set(baseline.classes.flatMap((definition) => [ definition.className, ...(classAliases.get(Number(definition.classId)) ?? []), ])); const specEvidence = collectSpecEvidence(specDbcSource.buffer, specDbcSource.header, specNames); const classes = baseline.classes.map((definition) => ({ ...definition })); const classCatalogResult = buildClassCatalogs(classes, cad.value, classDbc, specEvidence, diagnostics); for (const { classId, row } of classCatalogResult.filteredCadRows) { const numericId = Number(row.ID); if (!Number.isInteger(numericId)) { addDiagnostic(diagnostics, { code: "invalid-cad-row-id", severity: "warning", message: `Local CAD row for ${row.Class} has no numeric ID.`, classId, name: row.Name ?? "", }); } } const classIds = new Set(classes.map((definition) => Number(definition.classId))); const nodes = baseline.entries.filter((node) => classIds.has(Number(node.classId))).map((node) => ({ ...node })); const progressionChains = baseline.progressionChains .filter((chain) => classIds.has(Number(chain.classId))) .map((chain) => ({ ...chain, ranks: (chain.ranks ?? []).map((rank) => ({ ...rank })) })); const skillLineBySpellId = buildSkillLineIndex(skillDbcSource.buffer, skillDbcSource.header); const supportRoot = path.join(roots.extractedRoot, "client-current", "patch-S", "DBFilesClient"); const supportDefinitions = [ ["spell-cast-times", "SpellCastTimes.dbc", 2, "number"], ["spell-durations", "SpellDuration.dbc", 4, "number"], ["spell-radii", "SpellRadius.dbc", 4, "float"], ["spell-ranges", "SpellRange.dbc", 5, "range"], ]; const supports = { castTimes: null, durations: null, radii: null, ranges: null }; for (const [id, fileName, minimumFields, valueKind] of supportDefinitions) { const displayPath = `extracts/client-current/patch-S/DBFilesClient/${fileName}`; const source = addFileSource(sources, id, path.join(supportRoot, fileName), displayPath, "dbc", "support", false, minimumFields); if (!source) { addDiagnostic(diagnostics, { code: "missing-optional-support-dbc", severity: "warning", message: `${displayPath} is missing; dependent support values remain null when an index is nonzero.`, sourceId: id, }, (item) => `${item.code}:${item.sourceId}`); continue; } if (valueKind === "range") supports.ranges = rangeTable(source.buffer, source.header); else if (valueKind === "float") supports.radii = tableMap(source.buffer, source.header, 3, "float"); else if (fileName === "SpellCastTimes.dbc") supports.castTimes = tableMap(source.buffer, source.header, 1, "number"); else supports.durations = tableMap(source.buffer, source.header, 3, "number"); } const overlayPath = path.join(roots.extractedRoot, ...overlaySpellRelativePath.split("/")); const overlaySource = addFileSource(sources, "area52-spell-overlay", overlayPath, `extracts/${overlaySpellRelativePath}`, "dbc", "comparative-overlay", false, 234); const overlayContains = new Set(); if (overlaySource) { for (let row = 0; row < overlaySource.header.recordCount; row += 1) { overlayContains.add(field(overlaySource.buffer, overlaySource.header, 20 + row * overlaySource.header.recordSize, 0)); } } else { addDiagnostic(diagnostics, { code: "missing-comparative-spell-overlay", severity: "info", message: `Comparative overlay ${overlaySpellRelativePath} was not available; BASE patch-T remains the only Spell.dbc source.`, sourceId: "area52-spell-overlay", }); } const area52ListArchivePath = path.join(roots.clientRoot, "Data", "area-52", "listarchive"); const area52ListArchive = addFileSource(sources, "area52-listarchive", area52ListArchivePath, "client/Data/area-52/listarchive", "text", "source-order-evidence"); const globalListArchivePath = path.join(roots.clientRoot, "Data", "listarchive"); const globalListArchive = addFileSource(sources, "global-listarchive", globalListArchivePath, "client/Data/listarchive", "text", "source-order-evidence"); const extractionReadmePath = path.join(roots.extractedRoot, "client-current", "README.md"); const extractionReadme = addFileSource(sources, "extraction-readme", extractionReadmePath, "extracts/client-current/README.md", "text", "source-order-evidence"); const areaArchiveText = area52ListArchive?.buffer.toString("utf8").trim() ?? ""; const READMEText = extractionReadme?.buffer.toString("utf8") ?? ""; const hasPatchDArchive = /(?:^|\s)patch-D\.MPQ(?:\s|$)/im.test(areaArchiveText); const readmeClaimsRealmOverride = /area-52\/patch-D[\s\S]{0,180}realm override/i.test(READMEText); const readmeClaimsPatchTFallback = /patch-T[\s\S]{0,140}fallback/i.test(READMEText); if (!globalListArchive || hasPatchDArchive || (readmeClaimsRealmOverride && readmeClaimsPatchTFallback)) { addDiagnostic(diagnostics, { code: "source-order-conflict", severity: "warning", message: "Local source notes point to area-52 patch-D while this reference export selects global patch-T as BASE. The archive list and extraction README do not independently establish the runtime merge order; patch-D is recorded as a comparative overlay only.", evidence: { globalListArchivePresent: Boolean(globalListArchive), area52ListArchiveSelectsPatchD: hasPatchDArchive, extractionReadmeClaimsRealmOverride: readmeClaimsRealmOverride, extractionReadmeClaimsPatchTFallback: readmeClaimsPatchTFallback, }, }, (item) => item.code); } if (!globalListArchive) { addDiagnostic(diagnostics, { code: "global-listarchive-missing", severity: "info", message: "client/Data/listarchive is absent; only the area-52 listarchive is present in the local client tree.", sourceId: "global-listarchive", }, (item) => item.code); } const classFilteredRows = classCatalogResult.filteredCadRows; const { abilities, ownersBySpellId, rankRowsBySpell } = buildAbilities({ filteredCadRows: classFilteredRows, nodes, spellRankData: rankData.value, skillLineBySpellId, diagnostics, }); const spellEvidence = buildSpellEvidence({ nodes, extraNodes: options.extraNodes ?? [], baselineSpells: baseline.spells ?? [], progressionChains, abilities, spellRankData: rankData.value, ownersBySpellId, }); const seedSpellIds = new Set(spellEvidence.evidenceBySpell.keys()); const { records: spellRecords, triggerEvidenceBySpellId, archivedIds } = parseSpellRows(baseSpellSource.buffer, spellHeader, seedSpellIds, supports, diagnostics, options.archivedSpells); if (options.archivedSpells) sources.push({ ...options.archivedSpells.source, role: "missing-global-spell-supplement", kind: "raw-archive-dbc" }); for (const [spellId, triggerEvidence] of triggerEvidenceBySpellId) { const existing = spellEvidence.evidenceBySpell.get(spellId) ?? []; existing.push(...triggerEvidence); spellEvidence.evidenceBySpell.set(spellId, existing); } propagateTriggerClassOwnership( spellEvidence.classIdsBySpell, spellEvidence.evidenceBySpell, triggerEvidenceBySpellId, ); const spells = makeSpellRecords({ spellRecords, evidenceBySpell: spellEvidence.evidenceBySpell, classIdsBySpell: spellEvidence.classIdsBySpell, nodeNameBySpell: spellEvidence.nodeNameBySpell, rankRowsBySpell, diagnostics, overlayContains, archivedIds, archiveSource: options.archivedSpells?.source, }); const spellById = new Map(spells.map((spell) => [Number(spell.id), spell])); for (const chain of progressionChains) { for (const rank of chain.ranks ?? []) { if (spellById.get(Number(rank.spellId))?.dbc) continue; addDiagnostic(diagnostics, { code: "missing-progression-spell", severity: "warning", message: `Progression chain ${chain.id} references spell ${rank.spellId}, which has no local BASE Spell.dbc row.`, sourceId: "july-coa-node-baseline", chainId: chain.id, classId: Number(chain.classId), spellId: Number(rank.spellId), }, (item) => `${item.code}:${item.chainId}:${item.spellId}`); } } const inventory = { rootSet: roots.rootSet, spellTables: { base: { sourceId: spellSourceId, path: `extracts/${baseSpellRelativePath}`, sha256: baseSpellSource.source.sha256, bytes: baseSpellSource.source.bytes, recordCount: spellHeader.recordCount, fieldCount: spellHeader.fieldCount, }, overlay: overlaySource ? { sourceId: "area52-spell-overlay", path: `extracts/${overlaySpellRelativePath}`, sha256: overlaySource.source.sha256, bytes: overlaySource.source.bytes, recordCount: overlaySource.header.recordCount, fieldCount: overlaySource.header.fieldCount, } : null, }, }; delete diagnostics._keys; return { schemaVersion: 1, classes, nodes, spells, progressionChains, abilities, sources, diagnostics, baseline: { kind: "local-july", capturedAt: baseline.source.synchronizedAt, sourceId: "july-coa-node-baseline", }, classCatalogs: classCatalogResult.catalogs, inventory, ...(options.archivedSpells ? { archivedSpellSupplement: { source: options.archivedSpells.source, usedSpellIds: [...archivedIds].sort((a, b) => a - b), files: options.archivedSpells.files } } : {}), }; }