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.`);