#!/usr/bin/env node import { createHash } from "node:crypto"; import { createRequire } from "node:module"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { decodeWotlkM2Effects } from "./generate-manifest.mjs"; import { decodeWotlkM2Animation, decodeWotlkM2Mesh } from "./wotlk-m2-mesh.mjs"; import { convertBlpToPng, runtimeParticle } from "./package-priest-original.mjs"; const require = createRequire(import.meta.url); const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const prefix = "/assets/game/combat-effects/original/wow335/all"; const outputRoot = path.join(projectRoot, "public", prefix); const digest = (buffer) => createHash("sha256").update(buffer).digest("hex"); export function normalizeWowEffectPath(source, extension) { const normalized = source.replaceAll("\\", "/"); if (!normalized || normalized.startsWith("/") || normalized.includes(":") || normalized.split("/").some((part) => !part || part === "." || part === "..")) { throw new Error(`Invalid WoW source path: ${source}`); } return (extension ? normalized.replace(/\.[^/.]+$/, extension) : normalized).toLowerCase(); } // Local extracted client data first; base-client archives recover sparse textures. export function createWowEffectSource(options = {}) { const roots = options.roots ?? ["common-2.mpq", "patch-3", "patch-2", "patch", "expansion", "lichking", "common"] .map((name) => path.resolve(projectRoot, "../LadiksMPQEditor", name)); const cache = new Map(); let archives; return { read(source) { const normalized = normalizeWowEffectPath(source); if (cache.has(normalized)) return cache.get(normalized); const local = roots.map((root) => path.join(root, normalized)).find((file) => fs.existsSync(file)); if (local) { const result = { path: local, buffer: fs.readFileSync(local) }; cache.set(normalized, result); return result; } if (!archives) { const { MpqArchive } = require("stormlib-js"); archives = (options.archives ?? ["enUS/patch-enUS-3.MPQ", "enUS/patch-enUS-2.MPQ", "enUS/patch-enUS.MPQ", "enUS/lichking-locale-enUS.MPQ", "enUS/expansion-locale-enUS.MPQ", "enUS/locale-enUS.MPQ", "patch-3.MPQ", "patch-2.MPQ", "patch.MPQ", "lichking.MPQ", "expansion.MPQ", "common-2.MPQ", "common.MPQ"] .map((name) => path.resolve(projectRoot, "../wow335a/Data", name))) .filter((file) => fs.existsSync(file)).map((file) => MpqArchive.open(file)); } for (const archive of archives) { let buffer; try { buffer = archive.extractFile(normalized.replaceAll("/", "\\")); } catch { continue; } if (!buffer?.length) continue; const file = path.join(projectRoot, ".tools/wow-effects-source", normalized); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, buffer); const result = { path: file, buffer }; cache.set(normalized, result); return result; } cache.set(normalized, null); return null; }, close() { for (const archive of archives ?? []) archive.close(); }, }; } function ribbonTrack(track) { return { interpolation: track.interpolation, globalSequence: track.globalSequence, sequences: track.timestamps.sequences.map((sequence, index) => ({ timestamps: sequence.values, values: track.values.sequences[index].values, })), }; } export function packageWowOriginalEffects(manifest, options = {}) { const source = options.source ?? createWowEffectSource(options); const destination = options.outputRoot ?? outputRoot; const publicPrefix = options.publicPrefix ?? prefix; const converter = options.converter ?? path.resolve(projectRoot, "../wow-pvt-server-backups-2/azerothcore-wotlk/tools/WoTLK_MultiTool/BLPConverter8.exe"); const catalog = options.catalog ?? JSON.parse(fs.readFileSync(path.join(projectRoot, "src/game/wow335AbilityData.generated.json"))); const models = new Map(); const graphs = new Map(); const building = new Set(); const textures = new Map(); const failures = []; const report = { scope: "wow335-all-classes", models: 0, packagedModels: 0, meshModels: 0, particleEmitters: 0, ribbons: 0, textures: 0, spellEntries: 0, phaseAssets: 0, failures, classes: {} }; const packageTexture = (texture) => { if (!texture.filename) return { ...texture }; const name = normalizeWowEffectPath(texture.filename); if (textures.has(name)) return { ...texture, ...textures.get(name) }; const original = source.read(name); if (!original) throw new Error(`Missing texture: ${name}`); const hash = digest(original.buffer); const filename = `${hash.slice(0, 20)}.png`; const output = path.join(destination, "textures", filename); fs.mkdirSync(path.dirname(output), { recursive: true }); (options.convertTexture ?? convertBlpToPng)(original.path, output, converter); const packaged = { sourcePath: name, sourceSha256: hash, url: `${publicPrefix}/textures/${filename}` }; textures.set(name, packaged); return { ...texture, ...packaged }; }; const packageModel = (name) => { if (building.has(name)) throw new Error(`Cyclic effect dependency: ${name}`); building.add(name); const original = source.read(name); if (!original) throw new Error(`Missing model: ${name}`); const effects = decodeWotlkM2Effects(original.buffer, name); const animation = decodeWotlkM2Animation(original.buffer, name); const skinName = name.replace(/\.m2$/, "00.skin"); const skin = original.buffer.readUInt32LE(60) ? source.read(skinName) : null; if (original.buffer.readUInt32LE(60) && !skin) throw new Error(`Missing skin: ${skinName}`); const mesh = skin ? decodeWotlkM2Mesh(original.buffer, skin.buffer, name) : null; const packagedTextures = effects.textures.map((texture) => packageTexture( name === "creature/fleshbeast/fleshbeast.m2" && texture.type === 11 && !texture.filename ? { ...texture, filename: "Creature/FleshBeast/FleshBeastBlack.blp" } : texture, )); const readyParticleIndices = effects.particles.flatMap((particle, index) => ( packagedTextures[particle.textureIndex]?.url && particle.samples.emissionRate.some((sample) => (sample.value?.value ?? sample.value) > 0) && particle.samples.lifespan.some((sample) => (sample.value?.value ?? sample.value) > 0) ? [index] : [] )); const particleGraph = readyParticleIndices.length ? { schemaVersion: 1, kind: "wow-m2-particle-effect", source: "wow335", sourceModel: name, normalizedSourceModel: name, sourceSha256: digest(original.buffer), durationMs: effects.durationMs, animations: effects.animations, textures: packagedTextures, particles: effects.particles.map(runtimeParticle), runtime: { renderer: "wow-m2-particles-v1", readyParticleIndices, unsupportedRibbonCount: 0, unsupportedGeometryVertexCount: 0 }, } : null; const ribbons = effects.ribbons.map((ribbon) => ({ id: ribbon.id, parentBone: ribbon.parentBone, position: ribbon.position, textureIndices: ribbon.textureIndices.values, materialIndices: ribbon.materialIndices.values, edgesPerSecond: ribbon.edgesPerSecond, edgeLifetime: ribbon.edgeLifetime, gravity: ribbon.gravity, textureRows: ribbon.textureRows, textureColumns: ribbon.textureColumns, tracks: Object.fromEntries(Object.entries(ribbon.tracks).map(([key, track]) => [key, ribbonTrack(track)])), })); const graph = { schemaVersion: 1, kind: "wow-m2-model", source: "wow335", sourceModel: name, sourceSha256: digest(original.buffer), sourceSkinSha256: skin ? digest(skin.buffer) : null, animations: effects.animations, ...animation, textures: packagedTextures, particleGraph, ribbons, children: {}, mesh: mesh ? { positions: mesh.vertices.flatMap((vertex) => vertex.position), normals: mesh.vertices.flatMap((vertex) => vertex.normal), uvs: mesh.vertices.flatMap((vertex) => vertex.uv), uvs2: mesh.vertices.flatMap((vertex) => vertex.uv2), boneIndices: mesh.vertices.flatMap((vertex) => vertex.boneIndices), boneWeights: mesh.vertices.flatMap((vertex) => vertex.normalizedBoneWeights), indices: mesh.indices, colors: mesh.colors, textureWeights: mesh.textureWeights, layers: mesh.skin.textureUnits.map((unit) => ({ ...unit.drawRange, colorIndex: unit.colorIndex, materialIndex: unit.materialIndex, textureIndices: unit.textureIndices, textureCoordinateIndices: unit.textureCoordinateIndices, textureWeightIndex: unit.textureWeightIndices[0], textureTransformIndices: unit.textureTransformIndices, })), } : null, }; const dependencies = new Set(effects.particles.flatMap((particle) => [particle.geometryModel, particle.recursionModel]) .filter(Boolean).map((model) => normalizeWowEffectPath(model, ".m2"))); for (const dependency of dependencies) { if (!models.has(dependency)) { report.models++; models.set(dependency, packageModel(dependency)); } graph.children[dependency] = graphs.get(dependency); } if (!particleGraph && !mesh?.indices.length && !ribbons.length) throw new Error(`Model has no renderable components: ${name}`); const json = `${JSON.stringify(graph)}\n`; const filename = `${path.posix.basename(name, ".m2").replaceAll("_", "-")}-${digest(json).slice(0, 16)}.json`; fs.mkdirSync(path.join(destination, "graphs"), { recursive: true }); fs.writeFileSync(path.join(destination, "graphs", filename), json); report.packagedModels++; report.meshModels += mesh ? 1 : 0; report.particleEmitters += readyParticleIndices.length; report.ribbons += ribbons.length; graphs.set(name, graph); building.delete(name); return { effectKind: "wow-m2-model", effectGraphUrl: `${publicPrefix}/graphs/${filename}`, originalSourceSha256: graph.sourceSha256 }; }; try { const entries = manifest.entries.map((entry) => { if (entry.source !== "wow335") return entry; report.spellEntries++; return { ...entry, phases: Object.fromEntries(Object.entries(entry.phases ?? {}).map(([phase, assets]) => [phase, assets.map((asset) => { if (!asset.model) return asset; const name = normalizeWowEffectPath(asset.model, ".m2"); if (!models.has(name)) { report.models++; try { models.set(name, packageModel(name)); } catch (error) { failures.push({ model: name, reason: error.message }); models.set(name, null); } } const packaged = models.get(name); if (packaged) report.phaseAssets++; const { effectGraphUrl, effectKind, originalSourceSha256, ...rest } = asset; return { ...rest, ...(packaged ?? {}) }; }), ])) }; }); const byId = new Map(entries.filter((entry) => entry.source === "wow335").map((entry) => [entry.spellId, entry])); for (const [classId, chains] of Object.entries(catalog.classes)) { const ids = [...new Set(chains.flatMap((chain) => chain.ranks.map((rank) => rank.spellId)))]; const assets = ids.flatMap((id) => Object.values(byId.get(id)?.phases ?? {}).flat()); report.classes[classId] = { spellRanks: ids.length, withOriginalVisuals: ids.filter((id) => Object.values(byId.get(id)?.phases ?? {}).flat().some((asset) => asset.effectGraphUrl)).length, referencedPhaseAssets: assets.length, packagedPhaseAssets: assets.filter((asset) => asset.effectGraphUrl).length }; } report.textures = new Set([...textures.values()].map((texture) => texture.url)).size; return { manifest: { ...manifest, coverage: { ...manifest.coverage, originalVisuals: report }, entries }, report }; } finally { if (!options.source) source.close(); } } export function auditWowOriginalManifest(manifest, publicRoot = path.join(projectRoot, "public")) { const missing = new Set(); const urls = new Set(); for (const entry of manifest.entries) { for (const sound of entry.sounds ?? []) { if (sound.sourcePath && !sound.url) missing.add(`${entry.key}:${sound.sourcePath}`); if (sound.url && !fs.existsSync(path.join(publicRoot, sound.url))) missing.add(sound.url); } } for (const entry of manifest.entries.filter((entry) => entry.source === "wow335")) { for (const asset of Object.values(entry.phases ?? {}).flat()) { if (asset.model && !asset.effectGraphUrl) missing.add(`${entry.key}:${asset.model}`); if (asset.effectGraphUrl) urls.add(asset.effectGraphUrl); } } for (const url of urls) { const file = path.join(publicRoot, url); if (!fs.existsSync(file)) { missing.add(url); continue; } const graph = JSON.parse(fs.readFileSync(file)); const checkTextures = (model) => { for (const texture of model.textures ?? []) { if (texture.url && !fs.existsSync(path.join(publicRoot, texture.url))) missing.add(texture.url); } for (const child of Object.values(model.children ?? {})) checkTextures(child); }; checkTextures(graph); } return { graphs: urls.size, missing: [...missing].sort() }; } if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { const manifestPath = path.join(projectRoot, "public/assets/game/combat-effects/manifest.json"); const manifest = JSON.parse(fs.readFileSync(manifestPath)); if (process.argv.includes("--audit")) { const audit = auditWowOriginalManifest(manifest); console.log(JSON.stringify(audit)); if (audit.missing.length) process.exitCode = 1; } else { const result = packageWowOriginalEffects(manifest); console.log(JSON.stringify(result.report, null, 2)); if (result.report.failures.length) process.exitCode = 1; else fs.writeFileSync(manifestPath, `${JSON.stringify(result.manifest)}\n`); } }