Files
healer-man/scripts/combat-effects/package-priest-original.mjs
2026-09-11 18:49:33 -04:00

542 lines
22 KiB
JavaScript

#!/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,
);
}
export 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;
}
export 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,
globalSequences: decoded.globalSequences,
animations: decoded.animations,
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,
},
colors: decoded.colors,
textures,
textureWeights: decoded.textureWeights,
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;
const presentationAsset = isPowerWordShieldMesh
? { ...sourceAsset, scale: 0.7 }
: sourceAsset;
if (packaged.status !== "packaged") return presentationAsset;
attachedAssets += 1;
return {
...presentationAsset,
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}.`);
}