#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; 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", "generated", "wow335PetFamilyData.json"); const archivePath = path.resolve(process.argv[2] ?? defaultArchive); const outputPath = path.resolve(process.argv[3] ?? defaultOutput); function assertDbc(buffer, name, minimumFields) { 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 rowCount(buffer) { return buffer.readUInt32LE(4); } function recordSize(buffer) { return buffer.readUInt32LE(12); } function recordBase(index, size) { return 20 + index * size; } function textAt(buffer, base, firstField, shift = 0) { const count = rowCount(buffer); const stringsOffset = 20 + count * recordSize(buffer); const stringSize = buffer.readUInt32LE(16); for (let locale = 0; locale < 16; locale += 1) { const offset = buffer.readUInt32LE(base + (firstField + shift + locale) * 4); if (!offset || offset >= stringSize) continue; const start = stringsOffset + offset; const end = buffer.indexOf(0, start); const value = buffer.toString("utf8", start, end < 0 ? buffer.length : end); if (value) return value; } return ""; } function spellRows(buffer, durations) { assertDbc(buffer, "Spell.dbc", 234); const count = rowCount(buffer); const size = recordSize(buffer); const fields = buffer.readUInt32LE(8); const shift = fields >= 239 ? 6 : 0; const shifted = (field) => field >= 77 ? field + shift : field; const rows = new Map(); for (let index = 0; index < count; index += 1) { const base = recordBase(index, size); 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 effects = Array.from({ length: 3 }, (_, effectIndex) => { const effectType = unsigned(71 + effectIndex); const auraType = unsigned(shifted(95 + effectIndex)); const basePoints = signed(shifted(80 + effectIndex)) + 1; const triggerSpellId = unsigned(shifted(116 + effectIndex)); return { index: effectIndex, effectType, auraType, mechanic: unsigned(shifted(83 + effectIndex)), basePoints, dieSides: signed(74 + effectIndex), pointsPerLevel: float(shifted(77 + effectIndex)), periodMs: unsigned(shifted(98 + effectIndex)), miscValue: signed(shifted(110 + effectIndex)), triggerSpellId, coefficient: float(shifted(229 + effectIndex)), }; }).filter((effect) => effect.effectType || effect.auraType || effect.basePoints !== 1 || effect.triggerSpellId); const attributes0 = unsigned(4); rows.set(unsigned(0), { spellId: unsigned(0), name: textAt(buffer, base, 136, shift), rankText: textAt(buffer, base, 153, shift), mechanic: unsigned(3), requiredLevel: unsigned(39), passive: Boolean(attributes0 & 0x40), attributesEx1: unsigned(5), powerType: signed(41), powerCost: unsigned(42), cooldownMs: unsigned(29), categoryCooldownMs: unsigned(30), globalCooldownMs: unsigned(shifted(206)), auraInterruptFlags: unsigned(32), schoolMask: unsigned(shifted(225)), procFlags: unsigned(34), procChance: unsigned(35), procCharges: unsigned(36), durationMs: durations.get(unsigned(40)) ?? 0, effects, }); } return rows; } function durationTable(buffer) { assertDbc(buffer, "SpellDuration.dbc", 4); const durations = new Map(); const size = recordSize(buffer); for (let index = 0; index < rowCount(buffer); index += 1) { const base = recordBase(index, size); durations.set(buffer.readUInt32LE(base), buffer.readInt32LE(base + 3 * 4)); } return durations; } function familyRows(buffer) { assertDbc(buffer, "CreatureFamily.dbc", 28); const families = []; const size = recordSize(buffer); for (let index = 0; index < rowCount(buffer); index += 1) { const base = recordBase(index, size); const minScale = buffer.readFloatLE(base + 1 * 4); const maxScale = buffer.readFloatLE(base + 3 * 4); const petTalentType = buffer.readInt32LE(base + 8 * 4); const name = textAt(buffer, base, 10); if (!name) continue; families.push({ id: buffer.readUInt32LE(base), name, minScale, minScaleLevel: buffer.readUInt32LE(base + 2 * 4), maxScale, maxScaleLevel: buffer.readUInt32LE(base + 4 * 4), petFoodMask: buffer.readUInt32LE(base + 7 * 4), petTalentType, familySkillLineId: buffer.readUInt32LE(base + 5 * 4), commonPetSkillLineId: buffer.readUInt32LE(base + 6 * 4), categoryEnumId: buffer.readUInt32LE(base + 9 * 4), }); } return families; } function skillRows(buffer, lineIds, spells) { assertDbc(buffer, "SkillLineAbility.dbc", 14); const result = new Map([...lineIds].map((id) => [id, []])); const size = recordSize(buffer); for (let index = 0; index < rowCount(buffer); index += 1) { const base = recordBase(index, size); const lineId = buffer.readUInt32LE(base + 1 * 4); if (!result.has(lineId)) continue; const spellId = buffer.readUInt32LE(base + 2 * 4); const spell = spells.get(spellId); if (!spell) continue; result.get(lineId).push({ ...spell, acquireMethod: buffer.readUInt32LE(base + 9 * 4), raceMask: buffer.readUInt32LE(base + 3 * 4), classMask: buffer.readUInt32LE(base + 4 * 4), minSkill: buffer.readUInt32LE(base + 7 * 4), supersededBySpellId: buffer.readUInt32LE(base + 8 * 4), }); } for (const rows of result.values()) { rows.sort((left, right) => left.requiredLevel - right.requiredLevel || left.spellId - right.spellId); } return result; } const archive = MpqArchive.open(archivePath); let output; try { const extract = (name) => archive.extractFile(`DBFilesClient\\${name}.dbc`); const familyBuffer = extract("CreatureFamily"); const skillLineBuffer = extract("SkillLineAbility"); const spellBuffer = extract("Spell"); const durations = durationTable(extract("SpellDuration")); const spells = spellRows(spellBuffer, durations); const families = familyRows(familyBuffer); const lineIds = new Set(families.flatMap((family) => [family.familySkillLineId, family.commonPetSkillLineId]).filter(Boolean)); const skills = skillRows(skillLineBuffer, lineIds, spells); output = { source: { product: "World of Warcraft", build: "3.3.5a.12340", archive: path.relative(projectRoot, archivePath).replaceAll("\\", "/"), familyRows: rowCount(familyBuffer), skillLineAbilityRows: rowCount(skillLineBuffer), spellRows: rowCount(spellBuffer), }, families: families.map((family) => ({ ...family, hunterPet: family.petTalentType >= 0, familySkills: skills.get(family.familySkillLineId) ?? [], commonPetSkills: skills.get(family.commonPetSkillLineId) ?? [], })), }; } finally { archive.close(); } fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, `${JSON.stringify(output)}\n`); console.log(`Generated ${output.families.filter((family) => family.hunterPet).length} Hunter pet family records at ${outputPath}.`);