Files
healer-man/scripts/generate-wow335-talents.mjs
T
2026-08-14 15:56:39 -04:00

480 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <PBM_TalentData.lua> <Spell.dbc> <output.ts> [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 }));