import fs from "node:fs"; import path from "node:path"; const [luaPath, spellDbcPath, outputPath, talentDbcPath, baselineTalentDbcArgument] = process.argv.slice(2); if (!luaPath || !spellDbcPath || !outputPath) { throw new Error("Usage: node generate-wow335-talents.mjs [Talent.dbc] [baseline Talent.dbc]"); } const defaultBaselineTalentDbcPath = path.resolve( import.meta.dirname, "..", "..", "wow335a", "dungeon-export-work", "HealerMan", "talent-assets", "dbc", "Talent.dbc", ); const baselineTalentDbcPath = baselineTalentDbcArgument ? path.resolve(baselineTalentDbcArgument) : defaultBaselineTalentDbcPath; const treeMetadata = { DeathKnight: [ ["death-knight-blood", "Blood", 398], ["death-knight-frost", "Frost", 399], ["death-knight-unholy", "Unholy", 400], ], Druid: [ ["druid-balance", "Balance", 283], ["druid-feral-combat", "Feral Combat", 281], ["druid-restoration", "Restoration", 282], ], Hunter: [ ["hunter-beast-mastery", "Beast Mastery", 361], ["hunter-marksmanship", "Marksmanship", 363], ["hunter-survival", "Survival", 362], ], Mage: [ ["mage-arcane", "Arcane", 81], ["mage-fire", "Fire", 41], ["mage-frost", "Frost", 61], ], Paladin: [ ["paladin-holy", "Holy", 382], ["paladin-protection", "Protection", 383], ["paladin-retribution", "Retribution", 381], ], Priest: [ ["priest-discipline", "Discipline", 201], ["priest-holy", "Holy", 202], ["priest-shadow", "Shadow", 203], ], Rogue: [ ["rogue-assassination", "Assassination", 182], ["rogue-combat", "Combat", 181], ["rogue-subtlety", "Subtlety", 183], ], Shaman: [ ["shaman-elemental", "Elemental", 261], ["shaman-enhancement", "Enhancement", 263], ["shaman-restoration", "Restoration", 262], ], Warlock: [ ["warlock-affliction", "Affliction", 302], ["warlock-demonology", "Demonology", 303], ["warlock-destruction", "Destruction", 301], ], Warrior: [ ["warrior-arms", "Arms", 161], ["warrior-fury", "Fury", 164], ["warrior-protection", "Protection", 163], ], }; function dbcSpells(buffer) { if (buffer.toString("ascii", 0, 4) !== "WDBC") throw new Error("Spell.dbc 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); if (fieldCount < 188 || recordSize < fieldCount * 4) throw new Error("Unexpected 3.3.5 Spell.dbc schema"); const stringsOffset = 20 + recordCount * recordSize; const readString = (offset) => { if (!offset || offset >= stringSize) return ""; const start = stringsOffset + offset; const end = buffer.indexOf(0, start); return buffer.toString("utf8", start, end < 0 ? buffer.length : end); }; // The custom client DBC has six numeric columns inserted immediately before // the localized strings. Gameplay/effect columns retain the stock 3.3.5a // indices, but Name/Rank/Description/AuraDescription begin six fields later. const localizedFieldShift = fieldCount >= 239 ? 6 : 0; const spellEffectField = (field) => field >= 77 ? field + localizedFieldShift : field; const readLocalizedString = (base, firstField) => { for (let locale = 0; locale < 16; locale += 1) { const value = readString(buffer.readUInt32LE(base + (firstField + localizedFieldShift + locale) * 4)); if (value) return value; } return ""; }; const spells = new Map(); for (let index = 0; index < recordCount; index += 1) { const base = 20 + index * recordSize; const id = buffer.readUInt32LE(base); const unsigned = (field) => buffer.readUInt32LE(base + field * 4); const signed = (field) => buffer.readInt32LE(base + field * 4); const float = (field) => buffer.readFloatLE(base + field * 4); spells.set(id, { id, name: readLocalizedString(base, 136), rankText: readLocalizedString(base, 153), description: readLocalizedString(base, 170), auraDescription: readLocalizedString(base, 187), procChance: unsigned(35), procCharges: unsigned(36), stackAmount: unsigned(49), maxTargets: unsigned(spellEffectField(212)), durationIndex: unsigned(40), recoveryTimeMs: unsigned(29), categoryRecoveryTimeMs: unsigned(30), effects: Array.from({ length: 3 }, (_, effectIndex) => ({ index: effectIndex, effectType: unsigned(71 + effectIndex), auraType: unsigned(spellEffectField(95 + effectIndex)), basePoints: signed(spellEffectField(80 + effectIndex)) + 1, dieSides: signed(74 + effectIndex), pointsPerLevel: float(spellEffectField(77 + effectIndex)), mechanic: unsigned(spellEffectField(83 + effectIndex)), implicitTargetA: unsigned(spellEffectField(86 + effectIndex)), implicitTargetB: unsigned(spellEffectField(89 + effectIndex)), radiusIndex: unsigned(spellEffectField(92 + effectIndex)), periodMs: unsigned(spellEffectField(98 + effectIndex)), chainTargets: unsigned(spellEffectField(104 + effectIndex)), miscValue: signed(spellEffectField(110 + effectIndex)), miscValueB: signed(spellEffectField(113 + effectIndex)), triggerSpellId: unsigned(spellEffectField(116 + effectIndex)), classMask: [ unsigned(spellEffectField(122 + effectIndex)), unsigned(spellEffectField(125 + effectIndex)), unsigned(spellEffectField(128 + effectIndex)), ], coefficient: float(spellEffectField(229 + effectIndex)), })).filter((effect) => effect.effectType || effect.auraType || effect.basePoints !== 1 || effect.triggerSpellId), }); } return spells; } function dbcDurations(buffer) { if (buffer.toString("ascii", 0, 4) !== "WDBC") throw new Error("SpellDuration.dbc is not a WDBC file"); const recordCount = buffer.readUInt32LE(4); const recordSize = buffer.readUInt32LE(12); const durations = new Map(); for (let index = 0; index < recordCount; index += 1) { const base = 20 + index * recordSize; const id = buffer.readUInt32LE(base); // The maximum-duration column is the stable value in both the stock and // custom client files. A value of -1 means the effect lasts until removed. durations.set(id, buffer.readInt32LE(base + 3 * 4)); } return durations; } function dbcRadii(buffer) { if (buffer.toString("ascii", 0, 4) !== "WDBC") throw new Error("SpellRadius.dbc is not a WDBC file"); const recordCount = buffer.readUInt32LE(4); const recordSize = buffer.readUInt32LE(12); const radii = new Map(); for (let index = 0; index < recordCount; index += 1) { const base = 20 + index * recordSize; const id = buffer.readUInt32LE(base); const radius = buffer.readFloatLE(base + 3 * 4) || buffer.readFloatLE(base + 1 * 4); radii.set(id, radius); } return radii; } function cleanDescription(value) { return value .replace(/\|c[0-9a-f]{8}/gi, "") .replace(/\|r/gi, "") .replace(/@ext:/gi, "") .replace(/:ext@/gi, "") .replace(/[\r\n]+/g, " ") .replace(/\s+/g, " ") .trim(); } const source = fs.readFileSync(luaPath, "utf8"); const sectionStart = source.indexOf("PBM.data.talent.talents = {"); const sectionEnd = source.indexOf("PBM.data.talent.glyphs = {"); if (sectionStart < 0 || sectionEnd < 0) throw new Error("Talent section was not found in PBM_TalentData.lua"); const talentSource = source.slice(sectionStart, sectionEnd); const spells = dbcSpells(fs.readFileSync(spellDbcPath)); const extractedSupportDbcDirectory = path.resolve( path.dirname(spellDbcPath), "..", "..", "..", "patch-S", "DBFilesClient", ); const siblingDurationPath = path.join(path.dirname(spellDbcPath), "SpellDuration.dbc"); const durationPath = fs.existsSync(siblingDurationPath) ? siblingDurationPath : path.join(extractedSupportDbcDirectory, "SpellDuration.dbc"); const durations = fs.existsSync(durationPath) ? dbcDurations(fs.readFileSync(durationPath)) : new Map(); const siblingRadiusPath = path.join(path.dirname(spellDbcPath), "SpellRadius.dbc"); const radiusPath = fs.existsSync(siblingRadiusPath) ? siblingRadiusPath : path.join(extractedSupportDbcDirectory, "SpellRadius.dbc"); const radii = fs.existsSync(radiusPath) ? dbcRadii(fs.readFileSync(radiusPath)) : new Map([[18, 15]]); function formatNumber(value) { if (!Number.isFinite(value)) return "0"; return Number.isInteger(value) ? String(value) : String(Math.round(value * 100) / 100); } function formatDuration(milliseconds) { if (milliseconds === -1) return "until cancelled"; if (!Number.isFinite(milliseconds) || milliseconds <= 0) return "the listed duration"; if (milliseconds % 60000 === 0) { const minutes = milliseconds / 60000; return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`; } const seconds = milliseconds / 1000; return `${formatNumber(seconds)} ${seconds === 1 ? "second" : "seconds"}`; } function spellEffect(spell, oneBasedIndex) { return spell?.effects.find((effect) => effect.index === Math.max(0, Number(oneBasedIndex || 1) - 1)); } function resolveDescription(value, owningSpell) { const raw = cleanDescription(value); if (!raw) return "No client description is available for this rank."; return raw .replace(/\$\{\$([mMsS])(\d+)\/(\d+)\}/g, (_token, operator, effectIndex, divisor) => { const effect = spellEffect(owningSpell, effectIndex); const amount = Math.abs(operator === "M" ? (effect?.basePoints ?? 0) + Math.max(0, (effect?.dieSides ?? 1) - 1) : effect?.basePoints ?? 0) / Number(divisor); return formatNumber(amount); }) .replace(/\$\{\$([mMsS])(\d+)\/(\d+)\*\$(AP|RAP|SPH|SPN)\}/g, (_token, operator, effectIndex, divisor, stat) => { const effect = spellEffect(owningSpell, effectIndex); const amount = Math.abs(operator === "M" ? (effect?.basePoints ?? 0) + Math.max(0, (effect?.dieSides ?? 1) - 1) : effect?.basePoints ?? 0) / Number(divisor); const statName = { AP: "attack power", RAP: "ranged attack power", SPH: "healing spell power", SPN: "spell power" }[stat]; return `${formatNumber(amount)} × ${statName}`; }) .replace(/\$\*(\d+(?:\.\d+)?);(\d+)?[sS](\d+)/g, (_token, multiplier, referencedSpellId, effectIndex) => { const spell = referencedSpellId ? spells.get(Number(referencedSpellId)) : owningSpell; return formatNumber(Math.abs(spellEffect(spell, effectIndex)?.basePoints ?? 0) * Number(multiplier)); }) .replace(/\$\/(\d+(?:\.\d+)?);(\d+)?[sS](\d+)/g, (_token, divisor, referencedSpellId, effectIndex) => { const spell = referencedSpellId ? spells.get(Number(referencedSpellId)) : owningSpell; return formatNumber(Math.abs(spellEffect(spell, effectIndex)?.basePoints ?? 0) / Number(divisor)); }) .replace(/\$AP\b/g, "attack power") .replace(/\$RAP\b/g, "ranged attack power") .replace(/\$SPH\b/g, "healing spell power") .replace(/\$SPN\b/g, "spell power") .replace(/\$mw\b/gi, "weapon damage") .replace(/\$(\d+)?([sSmMtohdnui])(\d+)?/g, (_token, referencedSpellId, operator, effectIndex) => { const spell = referencedSpellId ? spells.get(Number(referencedSpellId)) : owningSpell; if (!spell) return "the listed amount"; const effect = spellEffect(spell, effectIndex); if (operator === "d") return formatDuration(durations.get(spell.durationIndex)); if (operator === "h") return formatNumber(spell.procChance); if (operator === "n") return formatNumber(spell.procCharges); if (operator === "u") return formatNumber(spell.stackAmount); if (operator === "i") return formatNumber(spell.maxTargets); if (operator === "t") return effect?.periodMs ? formatNumber(effect.periodMs / 1000) : "the listed interval"; if (operator === "o") { const duration = durations.get(spell.durationIndex); return effect?.periodMs && duration > 0 ? formatNumber(Math.abs(effect.basePoints) * duration / effect.periodMs) : formatNumber(Math.abs(effect?.basePoints ?? 0)); } if (!effect) return "the listed amount"; if (operator === "M") return formatNumber(Math.abs(effect.basePoints + Math.max(0, effect.dieSides - 1))); return formatNumber(Math.abs(effect.basePoints)); }) .replace(/\$a(\d+)/gi, (_token, effectIndex) => { const radiusIndex = spellEffect(owningSpell, effectIndex)?.radiusIndex ?? 0; return formatNumber(radii.get(radiusIndex) ?? radiusIndex); }) .replace(/\$l([^:;]+):([^;]+);/gi, "$2") .replace(/\$g([^:;]+):([^;]+);/gi, "$1") .replace(/\$[A-Za-z0-9?<>.:{}()+*/-]+/g, "the listed amount") .replace(/\s+/g, " ") .trim(); } let nodes = []; let currentClass = null; let currentTree = 0; for (const line of talentSource.split(/\r?\n/)) { const classMatch = line.match(/^\["([A-Za-z]+)"\]\s*=\s*\{$/); if (classMatch && treeMetadata[classMatch[1]]) { currentClass = classMatch[1]; currentTree = 0; continue; } const treeMatch = line.match(/^\[(\d+)\]\s*=\s*\{$/); if (treeMatch && currentClass) { currentTree = Number(treeMatch[1]); continue; } const talentMatch = line.match(/^\[(\d+)\]\s*=\s*"([^"]+)"/); if (!talentMatch || !currentClass || currentTree < 1 || currentTree > 3) continue; const index = Number(talentMatch[1]); const parts = talentMatch[2].split(",").map((part) => part.trim()); const prerequisiteIndex = Number(parts[0]); const column = Number(parts[1]); const row = Number(parts[2]); const iconBasename = parts[3].toLowerCase(); const rankSpellIds = parts.slice(4).map(Number).filter(Number.isFinite); const [treeId] = treeMetadata[currentClass][currentTree - 1]; const firstSpell = spells.get(rankSpellIds[0]); const lastSpell = spells.get(rankSpellIds.at(-1)); const rankSpells = rankSpellIds.map((spellId) => spells.get(spellId)).filter(Boolean); const rawRankDescriptions = rankSpells.map((spell) => cleanDescription(spell.description || spell.auraDescription)); const rankDescriptions = rankSpells.map((spell) => resolveDescription(spell.description || spell.auraDescription, spell)); nodes.push({ id: `${treeId}-${index}`, treeId, index, prerequisiteId: prerequisiteIndex > 0 ? `${treeId}-${prerequisiteIndex}` : null, prerequisiteRank: null, prerequisites: [], column, row, icon: `/assets/ui/talents/icons/${iconBasename}.png`, iconBasename, rankSpellIds, name: firstSpell?.name || `Talent ${rankSpellIds[0] ?? index}`, description: rankDescriptions.at(-1) || resolveDescription(lastSpell?.description || firstSpell?.description || "No client description is available for this rank.", lastSpell || firstSpell), rankDescriptions, rawRankDescriptions, rankEffects: rankSpells.map((spell) => ({ spellId: spell.id, rankText: cleanDescription(spell.rankText), procChance: spell.procChance, procCharges: spell.procCharges, stackAmount: spell.stackAmount, maxTargets: spell.maxTargets, durationIndex: spell.durationIndex, recoveryTimeMs: spell.recoveryTimeMs, categoryRecoveryTimeMs: spell.categoryRecoveryTimeMs, effects: spell.effects, })), }); } if (talentDbcPath) { const talentBuffer = fs.readFileSync(talentDbcPath); if (talentBuffer.toString("ascii", 0, 4) !== "WDBC") throw new Error("Talent.dbc is not a WDBC file"); const recordCount = talentBuffer.readUInt32LE(4); const fieldCount = talentBuffer.readUInt32LE(8); const recordSize = talentBuffer.readUInt32LE(12); if (fieldCount !== 23 || recordSize !== 92) throw new Error("Unexpected 3.3.5 Talent.dbc schema"); const treeByTabId = new Map(Object.values(treeMetadata).flat().map(([treeId, , tabId]) => [tabId, treeId])); const pbmByTreeAndSpell = new Map(nodes.map((node) => [`${node.treeId}:${node.rankSpellIds[0]}`, node])); const pbmByTreeAndCell = new Map(nodes.map((node) => [`${node.treeId}:${node.row}:${node.column}`, node])); const baselineDefinitionByTalentId = new Map(); if (fs.existsSync(baselineTalentDbcPath)) { const baselineBuffer = fs.readFileSync(baselineTalentDbcPath); const baselineRecordCount = baselineBuffer.readUInt32LE(4); const baselineRecordSize = baselineBuffer.readUInt32LE(12); for (let index = 0; index < baselineRecordCount; index += 1) { const base = 20 + index * baselineRecordSize; const field = (fieldIndex) => baselineBuffer.readUInt32LE(base + fieldIndex * 4); const treeId = treeByTabId.get(field(1)); if (!treeId) continue; const firstRankSpellId = field(4); const row = field(2) + 1; const column = field(3) + 1; const pbm = pbmByTreeAndSpell.get(`${treeId}:${firstRankSpellId}`) ?? pbmByTreeAndCell.get(`${treeId}:${row}:${column}`); if (pbm) { const prerequisites = Array.from({ length: 3 }, (_, prerequisite) => { const talentId = field(13 + prerequisite); if (!talentId) return null; return { talentId: `talent-${talentId}`, requiredRank: field(16 + prerequisite) + 1 }; }).filter(Boolean); baselineDefinitionByTalentId.set(field(0), { pbm, prerequisites }); } } } const exactNodes = []; for (let index = 0; index < recordCount; index += 1) { const base = 20 + index * recordSize; const field = (fieldIndex) => talentBuffer.readUInt32LE(base + fieldIndex * 4); const dbcTalentId = field(0); const treeId = treeByTabId.get(field(1)); if (!treeId) continue; const rankSpellIds = Array.from({ length: 9 }, (_, rank) => field(4 + rank)).filter(Boolean); const row = field(2) + 1; const column = field(3) + 1; // Ascension ships several additional talent systems in the same classic // TalentTab ids. Join by the stock Talent.dbc id map so those other // systems cannot be mixed into the default-class trees. const baselineDefinition = baselineDefinitionByTalentId.get(dbcTalentId); const pbm = baselineDefinitionByTalentId.size ? baselineDefinition?.pbm : pbmByTreeAndSpell.get(`${treeId}:${rankSpellIds[0]}`); if (!pbm) continue; const currentPrerequisites = Array.from({ length: 3 }, (_, prerequisite) => { const talentId = field(13 + prerequisite); if (!talentId) return null; return { talentId: `talent-${talentId}`, requiredRank: field(16 + prerequisite) + 1 }; }).filter(Boolean); const prerequisites = baselineDefinition?.prerequisites ?? currentPrerequisites; const firstSpell = spells.get(rankSpellIds[0]); const lastSpell = spells.get(rankSpellIds.at(-1)); const rankSpells = rankSpellIds.map((spellId) => spells.get(spellId)).filter(Boolean); const rawRankDescriptions = rankSpells.map((spell) => cleanDescription(spell.description || spell.auraDescription)); const rankDescriptions = rankSpells.map((spell) => resolveDescription(spell.description || spell.auraDescription, spell)); exactNodes.push({ id: `talent-${dbcTalentId}`, dbcTalentId, treeId, index: pbm.index, prerequisiteId: prerequisites[0]?.talentId ?? null, prerequisiteRank: prerequisites[0]?.requiredRank ?? null, prerequisites, column: pbm.column, row: pbm.row, icon: pbm.icon, iconBasename: pbm.iconBasename, rankSpellIds, name: firstSpell?.name || pbm.name, description: rankDescriptions.at(-1) || resolveDescription(lastSpell?.description || firstSpell?.description || pbm.description, lastSpell || firstSpell), rankDescriptions, rawRankDescriptions, rankEffects: rankSpells.map((spell) => ({ spellId: spell.id, rankText: cleanDescription(spell.rankText), procChance: spell.procChance, procCharges: spell.procCharges, stackAmount: spell.stackAmount, maxTargets: spell.maxTargets, durationIndex: spell.durationIndex, recoveryTimeMs: spell.recoveryTimeMs, categoryRecoveryTimeMs: spell.categoryRecoveryTimeMs, effects: spell.effects, })), flags: field(19), requiredSpellId: field(20), categoryMask: [field(21), field(22)], }); } nodes = exactNodes; } const expectedClasses = Object.keys(treeMetadata).length; const foundClasses = new Set(nodes.map((node) => node.treeId.split("-")[0])); if (nodes.length < 820 || nodes.length > 829 || foundClasses.size < expectedClasses - 1) { throw new Error(`Only parsed ${nodes.length} talent nodes; refusing to generate incomplete data`); } const treeLines = Object.entries(treeMetadata).flatMap(([className, trees]) => trees.map(([id, name, dbcTalentTabId], index) => ({ id, name, className, dbcTalentTabId, background: `/assets/ui/talents/backgrounds/talent_${className.toLowerCase()}${index + 1}.png`, }))); const output = `/* Generated from the local client DBCs. Do not hand-edit. */\n` + `export const WOW335_TALENT_TREE_ASSETS = ${JSON.stringify(treeLines, null, 2)} as const;\n\n` + `export const WOW335_TALENT_NODES = ${JSON.stringify(nodes, null, 2)} as const;\n`; fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, output, "utf8"); console.log(JSON.stringify({ nodes: nodes.length, trees: treeLines.length, outputPath }));