377 lines
14 KiB
JavaScript
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}.`);
|