Files
2026-09-27 14:31:15 -04:00

264 lines
14 KiB
JavaScript

#!/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`);
}
}