195 lines
8.0 KiB
JavaScript
195 lines
8.0 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const projectRoot = path.resolve(import.meta.dirname, "..");
|
|
const clientRoot = path.resolve(projectRoot, "..", "LadiksMPQEditor", "client-current");
|
|
const spellPath = path.join(clientRoot, "area-52", "patch-D", "DBFilesClient", "Spell.dbc");
|
|
const supportRoot = path.join(clientRoot, "patch-S", "DBFilesClient");
|
|
const runtimePath = path.join(projectRoot, "src", "game", "generated", "coaAbilityRuntimeCatalog.json");
|
|
const outputPath = path.join(projectRoot, "src", "game", "generated", "coaTriggeredSpellCatalog.json");
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) throw new Error(message);
|
|
}
|
|
|
|
function dbcNumberTable(filePath, valueField, floating = false) {
|
|
const buffer = fs.readFileSync(filePath);
|
|
assert(buffer.toString("ascii", 0, 4) === "WDBC", `${path.basename(filePath)} is not a WDBC file`);
|
|
const recordCount = buffer.readUInt32LE(4);
|
|
const recordSize = buffer.readUInt32LE(12);
|
|
return new Map(Array.from({ length: recordCount }, (_, index) => {
|
|
const base = 20 + index * recordSize;
|
|
return [
|
|
buffer.readUInt32LE(base),
|
|
floating ? buffer.readFloatLE(base + valueField * 4) : buffer.readInt32LE(base + valueField * 4),
|
|
];
|
|
}));
|
|
}
|
|
|
|
function dbcRangeTable(filePath) {
|
|
const buffer = fs.readFileSync(filePath);
|
|
assert(buffer.toString("ascii", 0, 4) === "WDBC", `${path.basename(filePath)} is not a WDBC file`);
|
|
const recordCount = buffer.readUInt32LE(4);
|
|
const recordSize = buffer.readUInt32LE(12);
|
|
return new Map(Array.from({ length: recordCount }, (_, index) => {
|
|
const base = 20 + index * recordSize;
|
|
return [buffer.readUInt32LE(base), {
|
|
min: Math.max(0, buffer.readFloatLE(base + 4), buffer.readFloatLE(base + 8)),
|
|
max: Math.max(0, buffer.readFloatLE(base + 12), buffer.readFloatLE(base + 16)),
|
|
}];
|
|
}));
|
|
}
|
|
|
|
function referencedSpellIds(runtime) {
|
|
const ids = new Set();
|
|
const inspectEffects = (effects) => {
|
|
for (const effect of effects ?? []) {
|
|
if (effect.kind === "trigger-spell" && effect.spellId > 0) ids.add(effect.spellId);
|
|
if (effect.kind !== "apply-aura") continue;
|
|
for (const proc of effect.aura?.procs ?? []) {
|
|
const spellId = proc.action?.kind === "custom" ? proc.action.data?.spellId : 0;
|
|
if (typeof spellId === "number" && spellId > 0) ids.add(spellId);
|
|
}
|
|
}
|
|
};
|
|
for (const abilities of Object.values(runtime.abilitiesByClass)) {
|
|
for (const ability of abilities) {
|
|
inspectEffects(ability.effects);
|
|
for (const rank of ability.ranks ?? []) inspectEffects(rank.effects);
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
function cleanText(value) {
|
|
return value.replace(/\r/g, "").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function spellParser(buffer, castTimes, durations, radii, ranges) {
|
|
assert(buffer.toString("ascii", 0, 4) === "WDBC", "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);
|
|
assert(fieldCount >= 234 && recordSize >= fieldCount * 4, "Unexpected Spell.dbc schema");
|
|
const stringsOffset = 20 + recordCount * recordSize;
|
|
const offsets = new Map(Array.from({ length: recordCount }, (_, index) => {
|
|
const base = 20 + index * recordSize;
|
|
return [buffer.readUInt32LE(base), base];
|
|
}));
|
|
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);
|
|
};
|
|
return (id) => {
|
|
const base = offsets.get(id);
|
|
if (base === undefined) return null;
|
|
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 + locale));
|
|
if (value) return cleanText(value);
|
|
}
|
|
return "";
|
|
};
|
|
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: float(77 + effectIndex),
|
|
mechanic: unsigned(83 + effectIndex),
|
|
implicitTargetA: unsigned(86 + effectIndex),
|
|
implicitTargetB: unsigned(89 + effectIndex),
|
|
radiusIndex: unsigned(92 + effectIndex),
|
|
radius: radii.get(unsigned(92 + effectIndex)) ?? 0,
|
|
periodMs: unsigned(98 + effectIndex),
|
|
valueMultiplier: float(101 + effectIndex),
|
|
chainTargets: unsigned(104 + effectIndex),
|
|
itemType: unsigned(107 + effectIndex),
|
|
miscValue: signed(110 + effectIndex),
|
|
miscValueB: signed(113 + effectIndex),
|
|
triggerSpellId: unsigned(116 + effectIndex),
|
|
pointsPerCombo: float(119 + effectIndex),
|
|
coefficient: float(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 }, (_, index) => unsigned(4 + index)),
|
|
castTimeIndex: unsigned(28),
|
|
castTimeMs: Math.max(0, castTimes.get(unsigned(28)) ?? 0),
|
|
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: unsigned(40),
|
|
durationMs: durations.get(unsigned(40)) ?? 0,
|
|
powerType: signed(41),
|
|
powerCost: unsigned(42),
|
|
powerCostPerLevel: unsigned(43),
|
|
powerCostPerSecond: unsigned(44),
|
|
powerCostPerSecondPerLevel: unsigned(45),
|
|
rangeIndex: unsigned(46),
|
|
rangeMin: ranges.get(unsigned(46))?.min ?? 0,
|
|
rangeMax: ranges.get(unsigned(46))?.max ?? 0,
|
|
projectileSpeed: float(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,
|
|
};
|
|
};
|
|
}
|
|
|
|
assert(fs.existsSync(spellPath), `CoA Spell.dbc was not found at ${spellPath}`);
|
|
const runtime = JSON.parse(fs.readFileSync(runtimePath, "utf8"));
|
|
const castTimes = dbcNumberTable(path.join(supportRoot, "SpellCastTimes.dbc"), 1);
|
|
const durations = dbcNumberTable(path.join(supportRoot, "SpellDuration.dbc"), 3);
|
|
const radii = dbcNumberTable(path.join(supportRoot, "SpellRadius.dbc"), 3, true);
|
|
const ranges = dbcRangeTable(path.join(supportRoot, "SpellRange.dbc"));
|
|
const parseSpell = spellParser(fs.readFileSync(spellPath), castTimes, durations, radii, ranges);
|
|
const pending = [...referencedSpellIds(runtime)];
|
|
const spells = new Map();
|
|
while (pending.length) {
|
|
const id = pending.shift();
|
|
if (spells.has(id)) continue;
|
|
const spell = parseSpell(id);
|
|
if (!spell) continue;
|
|
spells.set(id, spell);
|
|
for (const effect of spell.effects) {
|
|
if (effect.triggerSpellId > 0 && !spells.has(effect.triggerSpellId)) pending.push(effect.triggerSpellId);
|
|
}
|
|
}
|
|
|
|
fs.writeFileSync(outputPath, `${JSON.stringify({ schemaVersion: 1, spells: [...spells.values()] })}\n`, "utf8");
|
|
console.log(`Wrote ${outputPath} with ${spells.size} triggered spell records.`);
|