forked from phenom/healer-man
542 lines
22 KiB
JavaScript
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}.`);
|
|
}
|