#!/usr/bin/env node import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { decodeWotlkM2Effects } from "./generate-manifest.mjs"; import { decodeWotlkM2Mesh } from "./wotlk-m2-mesh.mjs"; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); const DEFAULT_PUBLIC_PREFIX = "/assets/game/combat-effects/original/wow335/priest"; const DEFAULT_OUTPUT_ROOT = path.join(projectRoot, "public", ...DEFAULT_PUBLIC_PREFIX.split("/").filter(Boolean)); const DEFAULT_MODEL_ROOTS = [ path.join(projectRoot, "..", "LadiksMPQEditor", "common-2.mpq"), path.join(projectRoot, "..", "LadiksMPQEditor", "patch-3"), path.join(projectRoot, "..", "LadiksMPQEditor", "patch-2"), path.join(projectRoot, "..", "LadiksMPQEditor", "patch"), ]; const DEFAULT_TEXTURE_ROOTS = [ path.join(projectRoot, "..", "LadiksMPQEditor", "common"), ...DEFAULT_MODEL_ROOTS, ]; const DEFAULT_BLP_CONVERTER = path.join( projectRoot, "..", "wow-pvt-server-backups-2", "azerothcore-wotlk", "tools", "WoTLK_MultiTool", "BLPConverter8.exe", ); const PRIORITY_PRIEST_SPELL_IDS = new Set([17, 139, 585, 589, 2050, 2061, 8122]); const POWER_WORD_SHIELD_SPELL_IDS = new Set([ 17, 592, 600, 3747, 6065, 6066, 10898, 10899, 10900, 10901, 25217, 25218, 48065, 48066, ]); const POWER_WORD_SHIELD_MODEL = "spells/holydivineshield_state_base.m2"; function sha256(buffer) { return createHash("sha256").update(buffer).digest("hex"); } function normalizedSourcePath(sourcePath, extension) { const normalized = sourcePath.replaceAll("\\", "/").replace(/^\/+/, ""); const segments = normalized.split("/").filter(Boolean); if (!segments.length || segments.some((segment) => segment === "." || segment === "..")) { throw new Error(`Unsafe original effect source path: ${sourcePath}`); } const currentExtension = path.posix.extname(segments.at(-1)); if (extension) segments[segments.length - 1] = `${segments.at(-1).slice(0, -currentExtension.length)}${extension}`; return segments.join("/"); } function sourceCandidate(root, sourcePath) { const absoluteRoot = path.resolve(root); const candidate = path.resolve(absoluteRoot, ...sourcePath.split("/")); const prefix = `${absoluteRoot}${path.sep}`.toLowerCase(); if (!candidate.toLowerCase().startsWith(prefix)) { throw new Error(`Refusing original effect source outside ${absoluteRoot}: ${candidate}`); } return candidate; } function resolveSource(roots, sourcePath) { for (const root of roots) { const candidate = sourceCandidate(root, sourcePath); if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate; } return null; } function slug(sourcePath) { return path.posix.basename(sourcePath, path.posix.extname(sourcePath)) .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") || "effect"; } function numericSampleValue(value) { if (typeof value === "number" && Number.isFinite(value)) return value; if (value && typeof value === "object" && typeof value.value === "number" && Number.isFinite(value.value)) { return value.value; } return 0; } function maximumSample(samples) { return Math.max(0, ...(samples ?? []).map((sample) => numericSampleValue(sample.value))); } function particleRuntimeReady(particle, textures) { return Boolean( textures[particle.textureIndex]?.url && maximumSample(particle.samples?.emissionRate) > 0 && maximumSample(particle.samples?.lifespan) > 0, ); } function convertBlpToPng(sourcePath, outputPath, converterPath) { if (fs.existsSync(outputPath) && fs.statSync(outputPath).size > 0) return; fs.mkdirSync(path.dirname(outputPath), { recursive: true }); const temporaryPath = `${outputPath}.tmp-${process.pid}`; fs.rmSync(temporaryPath, { force: true }); const result = spawnSync(converterPath, ["/FPNG_RGBA", sourcePath, temporaryPath], { encoding: "utf8", windowsHide: true, }); if (result.status !== 0 || !fs.existsSync(temporaryPath)) { fs.rmSync(temporaryPath, { force: true }); const detail = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); throw new Error(`BLP conversion failed for ${sourcePath}${detail ? `: ${detail}` : ""}`); } fs.renameSync(temporaryPath, outputPath); } export function priestSpellIdsFromCatalog(catalog) { const result = new Set(); for (const chain of catalog?.classes?.priest ?? []) { for (const rank of chain.ranks ?? []) { const spellId = Number(rank.spellId); if (Number.isInteger(spellId) && spellId > 0) result.add(spellId); } } for (const spellId of [585, 589, 2061, 8122]) result.add(spellId); return result; } function runtimeParticle(particle) { const animatedTrackNames = [ "emissionSpeed", "speedVariation", "verticalRange", "horizontalRange", "gravity", "lifespan", "emissionRate", "emissionAreaLength", "emissionAreaWidth", "zSource", "enabled", ]; return { id: particle.id, flags: particle.flags, position: particle.position, parentBone: particle.parentBone, textureIndex: particle.textureIndex, geometryModel: particle.geometryModel, recursionModel: particle.recursionModel, blendMode: particle.blendMode, emitterType: particle.emitterType, particleColorIndex: particle.particleColorIndex, particleType: particle.particleType, headOrTail: particle.headOrTail, textureTileRotation: particle.textureTileRotation, textureRows: particle.textureRows, textureColumns: particle.textureColumns, lifespanVariation: particle.lifespanVariation, emissionRateVariation: particle.emissionRateVariation, scaleVariation: particle.scaleVariation, tailLength: particle.tailLength, interpolation: Object.fromEntries(animatedTrackNames.map((name) => [ name, particle.tracks[name].interpolation, ])), globalSequence: Object.fromEntries(animatedTrackNames.map((name) => [ name, particle.tracks[name].globalSequence, ])), samples: { ...particle.samples, headCell: particle.tracks.headCell.samples, tailCell: particle.tracks.tailCell.samples, enabled: particle.tracks.enabled.firstSequence.samples, }, }; } function packageModel(sourceModel, options, missingTextures) { const normalizedModel = normalizedSourcePath(sourceModel, ".m2"); const sourcePath = resolveSource(options.modelRoots, normalizedModel); if (!sourcePath) return { status: "missing", sourceModel, normalizedModel }; const buffer = fs.readFileSync(sourcePath); const decoded = options.decodeM2(buffer, sourcePath); const referencedTextureIndices = new Set(decoded.particles.map((particle) => particle.textureIndex)); const textures = decoded.textures.map((texture, textureIndex) => { if (!referencedTextureIndices.has(textureIndex) || !texture.filename) return { ...texture }; const normalizedTexture = normalizedSourcePath(texture.filename, ".BLP"); const textureSourcePath = resolveSource(options.textureRoots, normalizedTexture); if (!textureSourcePath) { missingTextures.add(normalizedTexture); return { ...texture, sourcePath: normalizedTexture }; } const textureBuffer = fs.readFileSync(textureSourcePath); const textureHash = sha256(textureBuffer); const fileName = `${textureHash.slice(0, 20)}.png`; const outputPath = path.join(options.outputRoot, "textures", fileName); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); options.convertTexture(textureSourcePath, outputPath, options.blpConverter); return { ...texture, sourcePath: normalizedTexture, sourceSha256: textureHash, url: `${options.publicPrefix}/textures/${fileName}`, }; }); const readyParticleIndices = decoded.particles .map((particle, index) => particleRuntimeReady(particle, textures) ? index : -1) .filter((index) => index >= 0); if (!readyParticleIndices.length) { return { status: "unsupported", sourceModel, normalizedModel, particleCount: decoded.particles.length, ribbonCount: decoded.ribbons.length, vertexCount: buffer.readUInt32LE(60), }; } const modelHash = sha256(buffer); const fileName = `${slug(normalizedModel)}-${modelHash.slice(0, 12)}.json`; const graphUrl = `${options.publicPrefix}/graphs/${fileName}`; const graph = { schemaVersion: 1, kind: "wow-m2-particle-effect", source: "wow335", sourceModel, normalizedSourceModel: normalizedModel, sourceSha256: modelHash, durationMs: decoded.durationMs, animations: decoded.animations, textures, particles: decoded.particles.map(runtimeParticle), runtime: { renderer: "wow-m2-particles-v1", readyParticleIndices, unsupportedRibbonCount: decoded.ribbons.length, unsupportedGeometryVertexCount: buffer.readUInt32LE(60), }, }; const outputPath = path.join(options.outputRoot, "graphs", fileName); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, `${JSON.stringify(graph)}\n`); return { status: "packaged", sourceModel, normalizedModel, graphUrl, sourceSha256: modelHash, readyParticleCount: readyParticleIndices.length, particleCount: decoded.particles.length, ribbonCount: decoded.ribbons.length, vertexCount: buffer.readUInt32LE(60), }; } function packageStaticShieldModel(sourceModel, options, missingTextures) { const normalizedModel = normalizedSourcePath(sourceModel, ".m2"); const normalizedSkin = normalizedModel.replace(/\.m2$/i, "00.skin"); const sourcePath = resolveSource(options.modelRoots, normalizedModel); const skinPath = resolveSource(options.modelRoots, normalizedSkin); if (!sourcePath || !skinPath) { return { status: "missing", sourceModel, normalizedModel, normalizedSkin }; } const modelBuffer = fs.readFileSync(sourcePath); const skinBuffer = fs.readFileSync(skinPath); const decoded = options.decodeMesh(modelBuffer, skinBuffer, sourcePath); const referencedTextureIndices = new Set( decoded.skin.textureUnits.flatMap((textureUnit) => textureUnit.textureIndices), ); const textures = decoded.textures.map((texture, textureIndex) => { if (!referencedTextureIndices.has(textureIndex) || !texture.filename) return { ...texture }; const normalizedTexture = normalizedSourcePath(texture.filename, ".BLP"); const textureSourcePath = resolveSource(options.textureRoots, normalizedTexture); if (!textureSourcePath) { missingTextures.add(normalizedTexture); return { ...texture, sourcePath: normalizedTexture }; } const textureBuffer = fs.readFileSync(textureSourcePath); const textureHash = sha256(textureBuffer); const fileName = `${textureHash.slice(0, 20)}.png`; const outputPath = path.join(options.outputRoot, "textures", fileName); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); options.convertTexture(textureSourcePath, outputPath, options.blpConverter); return { ...texture, sourcePath: normalizedTexture, sourceSha256: textureHash, url: `${options.publicPrefix}/textures/${fileName}`, }; }); if ([...referencedTextureIndices].some((textureIndex) => !textures[textureIndex]?.url)) { return { status: "unsupported", sourceModel, normalizedModel, normalizedSkin, reason: "missing-static-mesh-texture", }; } const layers = decoded.skin.textureUnits.map((textureUnit, layerIndex) => { if (textureUnit.textureIndices.length !== 1) { throw new Error(`${sourcePath} static layer ${layerIndex} does not use exactly one texture.`); } const material = decoded.materials[textureUnit.materialIndex]; return { submeshIndex: textureUnit.submeshIndex, indexStart: textureUnit.drawRange.indexStart, indexCount: textureUnit.drawRange.indexCount, priorityPlane: textureUnit.priorityPlane, shaderId: textureUnit.shaderId, colorIndex: textureUnit.colorIndex, materialLayer: textureUnit.materialLayer, materialFlags: material.flags, blendMode: material.blendMode, textureIndex: textureUnit.textureIndices[0], textureCoordinateIndex: textureUnit.textureCoordinateIndices[0], textureWeightIndex: textureUnit.textureWeightIndices[0], textureTransformIndex: textureUnit.textureTransformIndices[0], }; }); const modelHash = sha256(modelBuffer); const skinHash = sha256(skinBuffer); const packageHash = sha256(Buffer.concat([modelBuffer, skinBuffer])); const fileName = `${slug(normalizedModel)}-${packageHash.slice(0, 12)}.json`; const graphUrl = `${options.publicPrefix}/graphs/${fileName}`; const graph = { schemaVersion: 1, kind: "wow-m2-static-mesh", source: "wow335", sourceModel, normalizedSourceModel: normalizedModel, sourceSha256: modelHash, sourceSkin: normalizedSkin, sourceSkinSha256: skinHash, bounds: decoded.bounds, geometry: { positions: decoded.vertices.flatMap((vertex) => vertex.position), normals: decoded.vertices.flatMap((vertex) => vertex.normal), uvs: decoded.vertices.flatMap((vertex) => vertex.uv), indices: decoded.indices, }, textures, layers, runtime: { renderer: "wow-m2-static-mesh-v1", coordinateSpace: "wow-m2-source", staticFrame: "source-bind-pose", }, }; const outputPath = path.join(options.outputRoot, "graphs", fileName); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, `${JSON.stringify(graph)}\n`); return { status: "packaged", effectKind: "wow-m2-static-mesh", sourceModel, normalizedModel, normalizedSkin, graphUrl, sourceSha256: modelHash, staticMeshCount: 1, }; } function publicUrlPath(publicUrl) { const normalized = publicUrl.replace(/^\/+/, ""); return path.join(projectRoot, "public", ...normalized.split("/")); } export function auditPriestOriginalManifest( manifest, prioritySpellIds = PRIORITY_PRIEST_SPELL_IDS, powerWordShieldSpellIds = POWER_WORD_SHIELD_SPELL_IDS, ) { const missingFiles = []; const readyPrioritySpells = new Set(); const readyPowerWordShieldAuraStarts = new Set(); for (const entry of manifest.entries ?? []) { for (const [phase, assets] of Object.entries(entry.phases ?? {})) { for (const asset of assets ?? []) { if (!asset.effectGraphUrl) continue; if (!fs.existsSync(publicUrlPath(asset.effectGraphUrl))) missingFiles.push(asset.effectGraphUrl); if (prioritySpellIds.has(entry.spellId)) readyPrioritySpells.add(entry.spellId); if (powerWordShieldSpellIds.has(entry.spellId) && phase === "aura-start" && asset.effectKind === "wow-m2-static-mesh" && asset.model && normalizedSourcePath(asset.model, ".m2").toLowerCase() === POWER_WORD_SHIELD_MODEL) { readyPowerWordShieldAuraStarts.add(entry.spellId); } } } } const missingPrioritySpells = [...prioritySpellIds].filter((spellId) => !readyPrioritySpells.has(spellId)); const missingPowerWordShieldAuraStarts = [...powerWordShieldSpellIds] .filter((spellId) => !readyPowerWordShieldAuraStarts.has(spellId)); return { missingFiles: [...new Set(missingFiles)].sort(), missingPrioritySpells, missingPowerWordShieldAuraStarts, }; } export function packagePriestOriginalEffects(manifest, options = {}) { const catalogPath = path.resolve(options.catalogPath ?? path.join(projectRoot, "src", "game", "wow335AbilityData.generated.json")); const catalog = options.catalog ?? JSON.parse(fs.readFileSync(catalogPath, "utf8")); const priestSpellIds = options.priestSpellIds ?? priestSpellIdsFromCatalog(catalog); const resolvedOptions = { modelRoots: (options.modelRoots ?? DEFAULT_MODEL_ROOTS).map((root) => path.resolve(root)), textureRoots: (options.textureRoots ?? DEFAULT_TEXTURE_ROOTS).map((root) => path.resolve(root)), outputRoot: path.resolve(options.outputRoot ?? DEFAULT_OUTPUT_ROOT), publicPrefix: options.publicPrefix ?? DEFAULT_PUBLIC_PREFIX, blpConverter: path.resolve(options.blpConverter ?? process.env.WOW_BLP_CONVERTER ?? DEFAULT_BLP_CONVERTER), decodeM2: options.decodeM2 ?? decodeWotlkM2Effects, decodeMesh: options.decodeMesh ?? decodeWotlkM2Mesh, convertTexture: options.convertTexture ?? convertBlpToPng, }; if (!fs.existsSync(resolvedOptions.blpConverter) && !options.convertTexture) { throw new Error(`BLP converter not found: ${resolvedOptions.blpConverter}`); } const modelResults = new Map(); const missingTextures = new Set(); let priestEntries = 0; let referencedAssets = 0; let attachedAssets = 0; const entries = (manifest.entries ?? []).map((entry) => { if (entry.source !== "wow335" || !priestSpellIds.has(entry.spellId)) return entry; priestEntries += 1; const phases = Object.fromEntries(Object.entries(entry.phases ?? {}).map(([phase, assets]) => [ phase, (assets ?? []).map((asset) => { if (!asset.model) return asset; referencedAssets += 1; const normalizedModel = normalizedSourcePath(asset.model, ".m2").toLowerCase(); const isPowerWordShieldMesh = POWER_WORD_SHIELD_SPELL_IDS.has(entry.spellId) && phase === "aura-start" && normalizedModel === POWER_WORD_SHIELD_MODEL; const key = `${isPowerWordShieldMesh ? "static" : "particles"}:${normalizedModel}`; if (!modelResults.has(key)) { modelResults.set(key, isPowerWordShieldMesh ? packageStaticShieldModel(asset.model, resolvedOptions, missingTextures) : packageModel(asset.model, resolvedOptions, missingTextures)); } const packaged = modelResults.get(key); const { effectGraphUrl: _oldUrl, effectKind: _oldKind, originalSourceSha256: _oldHash, ...sourceAsset } = asset; if (packaged.status !== "packaged") return sourceAsset; attachedAssets += 1; return { ...sourceAsset, effectKind: packaged.effectKind ?? "wow-m2-particles", effectGraphUrl: packaged.graphUrl, originalSourceSha256: packaged.sourceSha256, }; }), ])); return { ...entry, phases }; }); const results = [...modelResults.values()]; const packaged = results.filter((result) => result.status === "packaged"); const missingModels = results.filter((result) => result.status === "missing").map((result) => result.normalizedModel).sort(); const unsupportedModels = results.filter((result) => result.status === "unsupported").map((result) => result.normalizedModel).sort(); const report = { scope: "wow335-priest", renderer: "wow-m2-particles-v1", priestSpellIds: priestSpellIds.size, priestEntries, referencedAssets, uniqueModels: results.length, packagedModels: packaged.length, readyParticleEmitters: packaged.reduce((total, result) => total + (result.readyParticleCount ?? 0), 0), readyStaticMeshes: packaged.reduce((total, result) => total + (result.staticMeshCount ?? 0), 0), attachedAssets, missingModels, unsupportedModels, missingTextures: [...missingTextures].sort(), }; return { manifest: { ...manifest, coverage: { ...manifest.coverage, originalVisuals: report }, entries, }, report, }; } function parseArguments(arguments_) { const options = {}; for (let index = 0; index < arguments_.length; index += 1) { const argument = arguments_[index]; if (argument === "--audit") options.audit = true; else if (argument === "--manifest") options.manifestPath = arguments_[++index]; else if (argument === "--output-root") options.outputRoot = arguments_[++index]; else if (argument === "--model-root") (options.modelRoots ??= []).push(arguments_[++index]); else if (argument === "--texture-root") (options.textureRoots ??= []).push(arguments_[++index]); else if (argument === "--blp-converter") options.blpConverter = arguments_[++index]; else throw new Error(`Unknown argument: ${argument}`); } return options; } if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { const options = parseArguments(process.argv.slice(2)); const manifestPath = path.resolve(options.manifestPath ?? path.join(projectRoot, "public", "assets", "game", "combat-effects", "manifest.json")); const sourceManifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); const { manifest, report } = packagePriestOriginalEffects(sourceManifest, options); const audit = auditPriestOriginalManifest(manifest); if (options.audit && ( audit.missingFiles.length || audit.missingPrioritySpells.length || audit.missingPowerWordShieldAuraStarts.length )) { throw new Error( `Priest original-effect audit failed: ${audit.missingFiles.length} missing files; ` + `${audit.missingPrioritySpells.length} priority spells without a packaged effect; ` + `${audit.missingPowerWordShieldAuraStarts.length} Power Word: Shield aura-start phases without the static mesh.`, ); } if (!options.audit) fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); console.log( `Priest original effects: ${report.packagedModels}/${report.uniqueModels} source models, ` + `${report.readyParticleEmitters} particle emitters, ${report.readyStaticMeshes} static meshes, ` + `${report.attachedAssets} manifest phase assets.`, ); if (report.missingModels.length) console.warn(`Missing Priest models: ${report.missingModels.length}.`); if (report.missingTextures.length) console.warn(`Missing Priest textures: ${report.missingTextures.length}.`); }