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

377 lines
14 KiB
JavaScript

#!/usr/bin/env node
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const { MpqArchive } = require("stormlib-js");
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultArchive = path.resolve(projectRoot, "..", "wow335a", "Data", "enUS", "patch-enUS.MPQ");
const defaultOutput = path.join(projectRoot, "src", "game", "wow335AbilityData.generated.json");
const archivePath = path.resolve(process.argv[2] ?? defaultArchive);
const outputPath = path.resolve(process.argv[3] ?? defaultOutput);
const CLASS_SOURCES = Object.freeze({
warrior: { classMask: 1, skillLines: [26, 256, 257] },
paladin: { classMask: 2, skillLines: [594, 267, 184] },
hunter: { classMask: 4, skillLines: [50, 163, 51] },
rogue: { classMask: 8, skillLines: [253, 38, 39] },
priest: { classMask: 16, skillLines: [613, 56, 78] },
"death-knight": { classMask: 32, skillLines: [770, 771, 772] },
shaman: { classMask: 64, skillLines: [375, 373, 374] },
mage: { classMask: 128, skillLines: [237, 8, 6] },
warlock: { classMask: 256, skillLines: [355, 354, 593] },
druid: { classMask: 1024, skillLines: [574, 134, 573] },
});
// Triggered subspells can appear on a class skill line even though the player
// never learns them directly. Their owning active spell/talent remains present.
const TRIGGERED_ONLY_NAMES = new Set([
"Abolish Poison Effect",
"Backlash",
"Blade Twisting",
"Blood Corruption",
"Chilled",
"Curse of Doom Effect",
"Explosive Trap Effect",
"Fingers of Frost",
"Focused",
"Frostbite",
"Hellfire Effect",
"Holy Vengeance",
"Impact",
"Lightwell Renew",
"Master of Elements",
"Moonkin Aura",
"Pounce Bleed",
"Rampage",
"Ritual of Doom Effect",
"Safeguard",
"Second Wind",
"Trauma",
"Vindication",
]);
function assertDbc(buffer, name, minimumFields = 1) {
if (buffer.toString("ascii", 0, 4) !== "WDBC") throw new Error(`${name} is not WDBC data.`);
const fieldCount = buffer.readUInt32LE(8);
const recordSize = buffer.readUInt32LE(12);
if (fieldCount < minimumFields || recordSize !== fieldCount * 4) {
throw new Error(`${name} has an unsupported schema.`);
}
}
function numberTable(buffer, field, floating = false) {
assertDbc(buffer, "numeric support DBC", field + 1);
const count = buffer.readUInt32LE(4);
const recordSize = buffer.readUInt32LE(12);
const result = new Map();
for (let index = 0; index < count; index += 1) {
const base = 20 + index * recordSize;
const id = buffer.readUInt32LE(base);
result.set(id, floating
? buffer.readFloatLE(base + field * 4)
: buffer.readInt32LE(base + field * 4));
}
return result;
}
function rangeTable(buffer) {
assertDbc(buffer, "SpellRange.dbc", 5);
const count = buffer.readUInt32LE(4);
const recordSize = buffer.readUInt32LE(12);
const result = new Map();
for (let index = 0; index < count; index += 1) {
const base = 20 + index * recordSize;
const value = (field) => buffer.readFloatLE(base + field * 4);
result.set(buffer.readUInt32LE(base), {
min: Math.max(0, value(1), value(2)),
max: Math.max(0, value(3), value(4)),
});
}
return result;
}
function stringTable(buffer) {
assertDbc(buffer, "string support DBC", 2);
const count = buffer.readUInt32LE(4);
const recordSize = buffer.readUInt32LE(12);
const stringSize = buffer.readUInt32LE(16);
const stringsOffset = 20 + count * 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);
};
const result = new Map();
for (let index = 0; index < count; index += 1) {
const base = 20 + index * recordSize;
result.set(buffer.readUInt32LE(base), readString(buffer.readUInt32LE(base + 4)));
}
return result;
}
function cleanText(value) {
return value
.replace(/\|c[0-9a-f]{8}/gi, "")
.replace(/\|r/gi, "")
.replace(/[\r\n]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function parseSpells(buffer, castTimes, durations, radii, ranges, icons) {
assertDbc(buffer, "Spell.dbc", 234);
const count = buffer.readUInt32LE(4);
const fieldCount = buffer.readUInt32LE(8);
const recordSize = buffer.readUInt32LE(12);
const stringSize = buffer.readUInt32LE(16);
const stringsOffset = 20 + count * recordSize;
const shift = fieldCount >= 239 ? 6 : 0;
const shifted = (field) => field >= 77 ? field + shift : field;
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);
};
const spells = new Map();
for (let index = 0; index < count; index += 1) {
const base = 20 + index * recordSize;
const unsigned = (field) => buffer.readUInt32LE(base + field * 4);
const signed = (field) => buffer.readInt32LE(base + field * 4);
const float = (field) => buffer.readFloatLE(base + field * 4);
const localized = (firstField) => {
for (let locale = 0; locale < 16; locale += 1) {
const value = readString(unsigned(firstField + shift + locale));
if (value) return cleanText(value);
}
return "";
};
const spell = {
id: unsigned(0),
name: localized(136),
rankText: localized(153),
description: localized(170) || localized(187),
attributes: Array.from({ length: 8 }, (_, attribute) => unsigned(4 + attribute)),
castTimeMs: Math.max(0, castTimes.get(unsigned(28)) ?? 0),
recoveryTimeMs: unsigned(29),
categoryRecoveryTimeMs: unsigned(30),
channelInterruptFlags: unsigned(33),
maxLevel: unsigned(37),
baseLevel: unsigned(38),
spellLevel: unsigned(39),
durationMs: durations.get(unsigned(40)) ?? 0,
powerType: signed(41),
powerCost: unsigned(42),
rangeIndex: unsigned(46),
...(ranges.get(unsigned(46)) ?? { min: 0, max: 0 }),
equippedItemClass: signed(67),
equippedItemSubclassMask: signed(68),
iconBasename: path.basename(icons.get(unsigned(shifted(133))) || "inv_misc_questionmark")
.replace(/\.(?:blp|tga)$/i, "")
.toLowerCase(),
powerCostPercentage: unsigned(shifted(204)),
startRecoveryTimeMs: unsigned(shifted(206)),
effects: Array.from({ length: 3 }, (_, effectIndex) => ({
index: effectIndex,
effectType: unsigned(71 + effectIndex),
auraType: unsigned(shifted(95 + effectIndex)),
basePoints: signed(shifted(80 + effectIndex)) + 1,
dieSides: signed(74 + effectIndex),
pointsPerLevel: float(shifted(77 + effectIndex)),
mechanic: unsigned(shifted(83 + effectIndex)),
implicitTargetA: unsigned(shifted(86 + effectIndex)),
implicitTargetB: unsigned(shifted(89 + effectIndex)),
radius: radii.get(unsigned(shifted(92 + effectIndex))) ?? 0,
periodMs: unsigned(shifted(98 + effectIndex)),
miscValue: signed(shifted(110 + effectIndex)),
triggerSpellId: unsigned(shifted(116 + effectIndex)),
coefficient: float(shifted(229 + effectIndex)),
})).filter((effect) => (
effect.effectType
|| effect.auraType
|| effect.basePoints !== 1
|| effect.triggerSpellId
)),
};
spells.set(spell.id, spell);
}
return spells;
}
function talentSpellIds(buffer) {
assertDbc(buffer, "Talent.dbc", 23);
const count = buffer.readUInt32LE(4);
const recordSize = buffer.readUInt32LE(12);
const result = new Set();
for (let index = 0; index < count; index += 1) {
const base = 20 + index * recordSize;
for (let rank = 0; rank < 9; rank += 1) {
const spellId = buffer.readUInt32LE(base + (4 + rank) * 4);
if (spellId) result.add(spellId);
}
}
return result;
}
function classSpellIds(buffer, classSource) {
assertDbc(buffer, "SkillLineAbility.dbc", 14);
const count = buffer.readUInt32LE(4);
const recordSize = buffer.readUInt32LE(12);
const result = new Set();
for (let index = 0; index < count; index += 1) {
const base = 20 + index * recordSize;
const unsigned = (field) => buffer.readUInt32LE(base + field * 4);
if (
classSource.skillLines.includes(unsigned(1))
&& (unsigned(4) & classSource.classMask) !== 0
&& (unsigned(6) & classSource.classMask) === 0
) result.add(unsigned(2));
}
return result;
}
function rankNumber(spell) {
return Number(spell.rankText.match(/^Rank\s+(\d+)$/i)?.[1] ?? 0);
}
function resolveDescription(spell) {
const effect = (oneBasedIndex) => spell.effects.find(
(candidate) => candidate.index === Math.max(0, Number(oneBasedIndex || 1) - 1),
);
return (spell.description || "No client description is available.")
.replace(/\$(\d+)?[sSmM](\d+)?/g, (_token, _spellId, effectIndex) => {
const value = effect(effectIndex);
if (!value) return "the listed amount";
const maximum = Math.abs(value.basePoints) + Math.max(0, Math.abs(value.dieSides) - 1);
return String(Math.round(maximum * 100) / 100);
})
.replace(/\$(\d+)?t(\d+)?/g, (_token, _spellId, effectIndex) => {
const period = effect(effectIndex)?.periodMs ?? 0;
return period > 0 ? String(period / 1000) : "the listed interval";
})
.replace(/\$(\d+)?d\b/g, () => {
const milliseconds = Math.abs(spell.durationMs);
return milliseconds > 0 ? `${milliseconds / 1000} sec` : "the listed duration";
})
.replace(/\$[a-zA-Z0-9:;.*+/{}-]+/g, "the listed amount");
}
function slimRank(spell) {
return {
spellId: spell.id,
rank: rankNumber(spell),
level: spell.spellLevel,
sourceLevel: spell.baseLevel || spell.spellLevel,
maxScalingLevel: spell.maxLevel,
description: resolveDescription(spell),
castTimeMs: spell.castTimeMs,
cooldownMs: Math.max(spell.recoveryTimeMs, spell.categoryRecoveryTimeMs),
gcdMs: spell.startRecoveryTimeMs || 1500,
durationMs: Math.abs(spell.durationMs),
channelDurationMs: spell.channelInterruptFlags ? Math.max(0, spell.durationMs) : 0,
powerType: spell.powerType,
powerCost: spell.powerCost,
powerCostPercentage: spell.powerCostPercentage,
rangeIndex: spell.rangeIndex,
rangeMin: spell.min,
rangeMax: spell.max,
equippedItemClass: spell.equippedItemClass,
equippedItemSubclassMask: spell.equippedItemSubclassMask,
effects: spell.effects,
};
}
const archive = MpqArchive.open(archivePath);
let result;
try {
const extract = (name) => archive.extractFile(`DBFilesClient\\${name}.dbc`);
const spellBuffer = extract("Spell");
const skillLineBuffer = extract("SkillLineAbility");
const talentBuffer = extract("Talent");
const spells = parseSpells(
spellBuffer,
numberTable(extract("SpellCastTimes"), 1),
numberTable(extract("SpellDuration"), 3),
numberTable(extract("SpellRadius"), 3, true),
rangeTable(extract("SpellRange")),
stringTable(extract("SpellIcon")),
);
const talentIds = talentSpellIds(talentBuffer);
const passiveTalentNames = new Set(
[...talentIds]
.map((spellId) => spells.get(spellId))
.filter((spell) => spell && (spell.attributes[0] & 0x40) !== 0)
.map((spell) => spell.name),
);
const classes = {};
for (const [classId, source] of Object.entries(CLASS_SOURCES)) {
const selected = [...classSpellIds(skillLineBuffer, source)]
.map((spellId) => spells.get(spellId))
.filter((spell) => (
spell
&& spell.name
&& spell.spellLevel > 0
&& spell.spellLevel <= 80
&& (spell.attributes[0] & 0x40) === 0
));
const talentNames = new Set(selected.filter((spell) => talentIds.has(spell.id)).map((spell) => spell.name));
const chains = new Map();
for (const spell of selected) {
if (talentNames.has(spell.name) || TRIGGERED_ONLY_NAMES.has(spell.name)) continue;
const ranks = chains.get(spell.name) ?? [];
ranks.push(spell);
chains.set(spell.name, ranks);
}
classes[classId] = [...chains.entries()].map(([name, chainSpells]) => {
const ranks = [...new Map(chainSpells.map((spell) => [spell.id, spell])).values()]
.sort((left, right) => (
(rankNumber(left) || Number.MAX_SAFE_INTEGER) - (rankNumber(right) || Number.MAX_SAFE_INTEGER)
|| left.spellLevel - right.spellLevel
|| left.id - right.id
));
const first = ranks[0];
// Passive talents sometimes publish a separate, non-passive proc spell
// with the same name on the class skill line. That proc has no player
// activation mechanics, so retain it for the spellbook but classify it
// as passive instead of exposing it as a castable action.
const passive = passiveTalentNames.has(name) && ranks.every((spell) => (
spell.castTimeMs === 0
&& spell.recoveryTimeMs === 0
&& spell.categoryRecoveryTimeMs === 0
&& spell.powerCost === 0
&& spell.powerCostPercentage === 0
));
return {
id: `wow335-${classId}-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`,
name,
iconBasename: first.iconBasename,
sourceUnlockLevel: Math.min(...ranks.map((spell) => spell.spellLevel)),
...(passive ? { passive: true } : {}),
ranks: ranks.map(slimRank),
};
}).sort((left, right) => (
left.sourceUnlockLevel - right.sourceUnlockLevel
|| left.name.localeCompare(right.name)
));
}
result = {
source: {
product: "World of Warcraft",
build: "3.3.5a.12340",
archive: path.relative(projectRoot, archivePath).replaceAll("\\", "/"),
spellTableRecords: spellBuffer.readUInt32LE(4),
generatedAt: new Date().toISOString(),
},
classes,
};
} finally {
archive.close();
}
fs.writeFileSync(outputPath, `${JSON.stringify(result)}\n`);
console.log(`Generated ${Object.values(result.classes).flat().length} WoW 3.3.5a ability chains at ${outputPath}.`);