Release Healer Man 0.1.12

This commit is contained in:
phenom committed 2026-09-01 14:35:56 -04:00
1 parent 13970d03c8
commit 65e24f644e
201 files changed
+5061 -123

No files matched your search

+209 -39
View File
@@ -42,68 +42,227 @@ export function parseDbc(buffer, name = "DBC") {
return { name, buffer, recordCount, fieldCount, recordSize, stringSize, row };
}
function decodeM2TrackHeader(buffer, offset) {
if (offset < 0 || offset + 20 > buffer.length) return null;
const M2_VALUE_TYPES = {
float32: { byteLength: 4, read: (buffer, offset) => buffer.readFloatLE(offset) },
uint32: { byteLength: 4, read: (buffer, offset) => buffer.readUInt32LE(offset) },
uint16: { byteLength: 2, read: (buffer, offset) => buffer.readUInt16LE(offset) },
uint8: { byteLength: 1, read: (buffer, offset) => buffer.readUInt8(offset) },
vector2: {
byteLength: 8,
read: (buffer, offset) => [buffer.readFloatLE(offset), buffer.readFloatLE(offset + 4)],
},
vector3: {
byteLength: 12,
read: (buffer, offset) => [
buffer.readFloatLE(offset),
buffer.readFloatLE(offset + 4),
buffer.readFloatLE(offset + 8),
],
},
};
function assertM2Range(buffer, offset, byteLength, label) {
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(byteLength)
|| offset < 0 || byteLength < 0 || offset > buffer.length - byteLength) {
throw new Error(`${label} points outside the M2 buffer.`);
}
}
function readM2ArrayHeader(buffer, offset, label) {
assertM2Range(buffer, offset, 8, `${label} descriptor`);
return { count: buffer.readUInt32LE(offset), offset: buffer.readUInt32LE(offset + 4) };
}
function decodeM2Array(buffer, offset, valueType, label) {
const descriptor = readM2ArrayHeader(buffer, offset, label);
const byteLength = descriptor.count * valueType.byteLength;
if (!Number.isSafeInteger(byteLength)) throw new Error(`${label} is too large to decode.`);
if (descriptor.count) assertM2Range(buffer, descriptor.offset, byteLength, label);
return {
interpolation: buffer.readUInt16LE(offset),
globalSequence: buffer.readInt16LE(offset + 2),
timeCount: buffer.readUInt32LE(offset + 4),
timeOffset: buffer.readUInt32LE(offset + 8),
keyCount: buffer.readUInt32LE(offset + 12),
keyOffset: buffer.readUInt32LE(offset + 16),
...descriptor,
values: Array.from({ length: descriptor.count }, (_, index) => (
valueType.read(buffer, descriptor.offset + index * valueType.byteLength)
)),
};
}
/** Decode the stable WotLK 3.x particle/ribbon fields used by the browser fallback converter. */
function decodeM2NestedArray(buffer, offset, valueType, label) {
const descriptor = readM2ArrayHeader(buffer, offset, label);
const byteLength = descriptor.count * 8;
if (!Number.isSafeInteger(byteLength)) throw new Error(`${label} is too large to decode.`);
if (descriptor.count) assertM2Range(buffer, descriptor.offset, byteLength, `${label} sequence descriptors`);
return {
...descriptor,
sequences: Array.from({ length: descriptor.count }, (_, index) => (
decodeM2Array(buffer, descriptor.offset + index * 8, valueType, `${label} sequence ${index}`)
)),
};
}
function decodeM2StringArray(buffer, offset, label) {
const descriptor = readM2ArrayHeader(buffer, offset, label);
if (!descriptor.count) return "";
assertM2Range(buffer, descriptor.offset, descriptor.count, label);
const bytes = buffer.subarray(descriptor.offset, descriptor.offset + descriptor.count);
const terminator = bytes.indexOf(0);
return bytes.toString("utf8", 0, terminator < 0 ? bytes.length : terminator);
}
function decodeM2Track(buffer, offset, valueType, label) {
assertM2Range(buffer, offset, 20, `${label} track`);
const interpolation = buffer.readUInt16LE(offset);
if (interpolation > 3) throw new Error(`${label} uses unsupported interpolation ${interpolation}.`);
const spline = interpolation === 2 || interpolation === 3;
const trackValueType = spline ? {
byteLength: valueType.byteLength * 3,
read: (source, valueOffset) => ({
value: valueType.read(source, valueOffset),
inTangent: valueType.read(source, valueOffset + valueType.byteLength),
outTangent: valueType.read(source, valueOffset + valueType.byteLength * 2),
}),
} : valueType;
const timestamps = decodeM2NestedArray(buffer, offset + 4, M2_VALUE_TYPES.uint32, `${label} timestamps`);
const values = decodeM2NestedArray(buffer, offset + 12, trackValueType, `${label} values`);
const firstTimestamps = timestamps.sequences[0]?.values ?? [];
const firstValues = values.sequences[0]?.values ?? [];
return {
kind: "animated",
interpolation,
globalSequence: buffer.readInt16LE(offset + 2),
timestamps,
values,
firstSequence: {
timestamps: firstTimestamps,
values: firstValues,
samples: Array.from({ length: Math.min(firstTimestamps.length, firstValues.length) }, (_, index) => ({
timeMs: firstTimestamps[index],
value: firstValues[index],
})),
},
};
}
function decodeM2ParticleLifetimeTrack(buffer, offset, valueType, label) {
const timestamps = decodeM2Array(buffer, offset, M2_VALUE_TYPES.uint16, `${label} timestamps`);
const values = decodeM2Array(buffer, offset + 8, valueType, `${label} values`);
return {
kind: "particle-lifetime",
timestamps,
values,
samples: Array.from({ length: Math.min(timestamps.values.length, values.values.length) }, (_, index) => ({
timestamp: timestamps.values[index],
normalizedTime: timestamps.values[index] / 32_767,
value: values.values[index],
})),
};
}
/** Decode render-neutral descriptors from the WotLK 3.3.5 (version 264) MD20 layout. */
export function decodeWotlkM2Effects(buffer, name = "M2") {
if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer);
const magic = buffer.toString("ascii", 0, 4);
if ((magic !== "MD20" && magic !== "MD21") || buffer.length < 304) {
throw new Error(`${name} is not a supported WotLK M2.`);
if (magic !== "MD20" || buffer.length < 304 || buffer.readUInt32LE(4) !== 264) {
throw new Error(`${name} is not a supported WotLK 3.3.5 MD20.`);
}
const version = buffer.readUInt32LE(4);
const animationCount = buffer.readUInt32LE(28);
const animationOffset = buffer.readUInt32LE(32);
const textureCount = buffer.readUInt32LE(80);
const textureOffset = buffer.readUInt32LE(84);
const ribbonCount = buffer.readUInt32LE(288);
const ribbonOffset = buffer.readUInt32LE(292);
const particleCount = buffer.readUInt32LE(296);
const particleOffset = buffer.readUInt32LE(300);
const particleRecordSize = 476;
const animationRecordSize = 64;
const textureRecordSize = 16;
const ribbonRecordSize = 176;
if (particleCount && particleOffset + particleCount * particleRecordSize > buffer.length) {
throw new Error(`${name} has truncated particle records.`);
}
if (ribbonCount && ribbonOffset + ribbonCount * ribbonRecordSize > buffer.length) {
throw new Error(`${name} has truncated ribbon records.`);
}
const trackOffsets = {
emissionSpeed: 56,
speedVariation: 76,
verticalRange: 96,
horizontalRange: 116,
gravity: 136,
lifespan: 156,
emissionRate: 180,
const particleRecordSize = 476;
if (animationCount) assertM2Range(buffer, animationOffset, animationCount * animationRecordSize, `${name} animation records`);
if (textureCount) assertM2Range(buffer, textureOffset, textureCount * textureRecordSize, `${name} texture records`);
if (ribbonCount) assertM2Range(buffer, ribbonOffset, ribbonCount * ribbonRecordSize, `${name} ribbon records`);
if (particleCount) assertM2Range(buffer, particleOffset, particleCount * particleRecordSize, `${name} particle records`);
const animations = Array.from({ length: animationCount }, (_, index) => {
const offset = animationOffset + index * animationRecordSize;
return {
index,
id: buffer.readUInt16LE(offset),
variationIndex: buffer.readUInt16LE(offset + 2),
durationMs: buffer.readUInt32LE(offset + 4),
moveSpeed: buffer.readFloatLE(offset + 8),
flags: buffer.readUInt32LE(offset + 12),
};
});
const durationMs = animations.reduce((maximum, animation) => Math.max(maximum, animation.durationMs), 0);
const textures = Array.from({ length: textureCount }, (_, index) => {
const offset = textureOffset + index * textureRecordSize;
return {
type: buffer.readUInt32LE(offset),
flags: buffer.readUInt32LE(offset + 4),
filename: decodeM2StringArray(buffer, offset + 8, `${name} texture ${index} filename`),
};
});
const animatedTrackOffsets = {
emissionSpeed: 52,
speedVariation: 72,
verticalRange: 92,
horizontalRange: 112,
gravity: 132,
lifespan: 152,
emissionRate: 176,
emissionAreaLength: 200,
emissionAreaWidth: 220,
zSource: 240,
color: 260,
alpha: 280,
scale: 300,
};
const particles = Array.from({ length: particleCount }, (_, index) => {
const offset = particleOffset + index * particleRecordSize;
const tracks = Object.fromEntries(Object.entries(animatedTrackOffsets).map(([track, relativeOffset]) => [
track,
decodeM2Track(buffer, offset + relativeOffset, M2_VALUE_TYPES.float32, `${name} particle ${index} ${track}`),
]));
tracks.color = decodeM2ParticleLifetimeTrack(
buffer, offset + 260, M2_VALUE_TYPES.vector3, `${name} particle ${index} color`,
);
tracks.alpha = decodeM2ParticleLifetimeTrack(
buffer, offset + 276, M2_VALUE_TYPES.uint16, `${name} particle ${index} alpha`,
);
tracks.scale = decodeM2ParticleLifetimeTrack(
buffer, offset + 292, M2_VALUE_TYPES.vector2, `${name} particle ${index} scale`,
);
tracks.headCell = decodeM2ParticleLifetimeTrack(
buffer, offset + 316, M2_VALUE_TYPES.uint16, `${name} particle ${index} head cell`,
);
tracks.tailCell = decodeM2ParticleLifetimeTrack(
buffer, offset + 332, M2_VALUE_TYPES.uint16, `${name} particle ${index} tail cell`,
);
tracks.enabled = decodeM2Track(
buffer, offset + 456, M2_VALUE_TYPES.uint8, `${name} particle ${index} enabled`,
);
return {
id: buffer.readUInt32LE(offset),
flags: buffer.readUInt32LE(offset + 4),
position: [buffer.readFloatLE(offset + 8), buffer.readFloatLE(offset + 12), buffer.readFloatLE(offset + 16)],
position: M2_VALUE_TYPES.vector3.read(buffer, offset + 8),
parentBone: buffer.readUInt16LE(offset + 20),
textureIndex: buffer.readUInt16LE(offset + 22),
geometryModel: decodeM2StringArray(buffer, offset + 24, `${name} particle ${index} geometry model`),
recursionModel: decodeM2StringArray(buffer, offset + 32, `${name} particle ${index} recursion model`),
blendMode: buffer.readUInt8(offset + 40),
emitterType: buffer.readUInt8(offset + 41),
particleColorIndex: buffer.readUInt16LE(offset + 42),
particleType: buffer.readUInt8(offset + 44),
headOrTail: buffer.readUInt8(offset + 45),
textureTileRotation: buffer.readUInt16LE(offset + 46),
textureRows: buffer.readUInt16LE(offset + 48),
textureColumns: buffer.readUInt16LE(offset + 50),
tracks: Object.fromEntries(Object.entries(trackOffsets)
.map(([track, relativeOffset]) => [track, decodeM2TrackHeader(buffer, offset + relativeOffset)])),
lifespanVariation: buffer.readFloatLE(offset + 172),
emissionRateVariation: buffer.readFloatLE(offset + 196),
scaleVariation: M2_VALUE_TYPES.vector2.read(buffer, offset + 308),
tailLength: buffer.readFloatLE(offset + 348),
tracks,
samples: Object.fromEntries([
...Object.keys(animatedTrackOffsets).map((track) => [track, tracks[track].firstSequence.samples]),
...["color", "alpha", "scale"].map((track) => [track, tracks[track].samples]),
]),
};
});
const ribbons = Array.from({ length: ribbonCount }, (_, index) => {
@@ -111,15 +270,26 @@ export function decodeWotlkM2Effects(buffer, name = "M2") {
return {
id: buffer.readUInt32LE(offset),
parentBone: buffer.readUInt32LE(offset + 4),
position: [buffer.readFloatLE(offset + 8), buffer.readFloatLE(offset + 12), buffer.readFloatLE(offset + 16)],
textureCount: buffer.readUInt32LE(offset + 20),
textureOffset: buffer.readUInt32LE(offset + 24),
materialCount: buffer.readUInt32LE(offset + 28),
materialOffset: buffer.readUInt32LE(offset + 32),
colorTrack: decodeM2TrackHeader(buffer, offset + 36),
position: M2_VALUE_TYPES.vector3.read(buffer, offset + 8),
textureIndices: decodeM2Array(buffer, offset + 20, M2_VALUE_TYPES.uint16, `${name} ribbon ${index} textures`),
materialIndices: decodeM2Array(buffer, offset + 28, M2_VALUE_TYPES.uint16, `${name} ribbon ${index} materials`),
edgesPerSecond: buffer.readFloatLE(offset + 116),
edgeLifetime: buffer.readFloatLE(offset + 120),
gravity: buffer.readFloatLE(offset + 124),
textureRows: buffer.readUInt16LE(offset + 128),
textureColumns: buffer.readUInt16LE(offset + 130),
priorityPlane: buffer.readInt16LE(offset + 172),
tracks: {
color: decodeM2Track(buffer, offset + 36, M2_VALUE_TYPES.vector3, `${name} ribbon ${index} color`),
alpha: decodeM2Track(buffer, offset + 56, M2_VALUE_TYPES.uint16, `${name} ribbon ${index} alpha`),
heightAbove: decodeM2Track(buffer, offset + 76, M2_VALUE_TYPES.float32, `${name} ribbon ${index} height above`),
heightBelow: decodeM2Track(buffer, offset + 96, M2_VALUE_TYPES.float32, `${name} ribbon ${index} height below`),
textureSlot: decodeM2Track(buffer, offset + 132, M2_VALUE_TYPES.uint16, `${name} ribbon ${index} texture slot`),
visibility: decodeM2Track(buffer, offset + 152, M2_VALUE_TYPES.uint8, `${name} ribbon ${index} visibility`),
},
};
});
return { magic, particles, ribbons };
return { magic, version, durationMs, animations, textures, particles, ribbons };
}
function tableById(table, decode) {
+225 -27
View File
@@ -1,9 +1,11 @@
import assert from "node:assert/strict";
import test from "node:test";
import { decodeWotlkM2Effects, parseDbc, parseRunewakerImageRecords } from "./generate-manifest.mjs";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { decodeWotlkM2Effects, parseDbc, parseRunewakerImageRecords } from "./generate-manifest.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
function tinyDbc() {
const strings = Buffer.from("\0hello\0", "utf8");
@@ -19,6 +21,132 @@ function tinyDbc() {
return buffer;
}
function wotlkParticleFixture() {
const particleOffset = 304;
const particleRecordSize = 476;
const animationOffset = particleOffset + particleRecordSize + 20;
const textureOffset = animationOffset + 64;
const textureFilenameOffset = textureOffset + 16;
const geometryFilenameOffset = textureFilenameOffset + 64;
const buffer = Buffer.alloc(4096);
let cursor = 1024;
const allocate = (byteLength, alignment = 4) => {
cursor = Math.ceil(cursor / alignment) * alignment;
const offset = cursor;
cursor += byteLength;
assert.ok(cursor <= buffer.length, "synthetic M2 fixture overflow");
return offset;
};
const writeArray = (descriptorOffset, values, byteLength, write) => {
buffer.writeUInt32LE(values.length, descriptorOffset);
if (!values.length) {
buffer.writeUInt32LE(0, descriptorOffset + 4);
return;
}
const valueOffset = allocate(values.length * byteLength, Math.min(byteLength, 4));
buffer.writeUInt32LE(valueOffset, descriptorOffset + 4);
values.forEach((value, index) => write(valueOffset + index * byteLength, value));
};
const writeNestedArray = (descriptorOffset, sequences, byteLength, write) => {
buffer.writeUInt32LE(sequences.length, descriptorOffset);
const sequenceOffset = sequences.length ? allocate(sequences.length * 8) : 0;
buffer.writeUInt32LE(sequenceOffset, descriptorOffset + 4);
sequences.forEach((values, index) => {
writeArray(sequenceOffset + index * 8, values, byteLength, write);
});
};
const writeFloatTrack = (relativeOffset, sequences) => {
const offset = particleOffset + relativeOffset;
buffer.writeUInt16LE(1, offset);
buffer.writeInt16LE(-1, offset + 2);
writeNestedArray(offset + 4, sequences.map((sequence) => sequence.timestamps), 4, (valueOffset, value) => {
buffer.writeUInt32LE(value, valueOffset);
});
writeNestedArray(offset + 12, sequences.map((sequence) => sequence.values), 4, (valueOffset, value) => {
buffer.writeFloatLE(value, valueOffset);
});
};
buffer.write("MD20", 0, "ascii");
buffer.writeUInt32LE(264, 4);
buffer.writeUInt32LE(1, 28);
buffer.writeUInt32LE(animationOffset, 32);
buffer.writeUInt32LE(1, 80);
buffer.writeUInt32LE(textureOffset, 84);
buffer.writeUInt32LE(1, 296);
buffer.writeUInt32LE(particleOffset, 300);
buffer.writeUInt16LE(0, animationOffset);
buffer.writeUInt16LE(0, animationOffset + 2);
buffer.writeUInt32LE(1500, animationOffset + 4);
buffer.writeFloatLE(0, animationOffset + 8);
buffer.writeUInt32LE(33, animationOffset + 12);
const textureFilename = Buffer.from("Spells\\FixtureGlow.blp\0", "utf8");
buffer.writeUInt32LE(0, textureOffset);
buffer.writeUInt32LE(0, textureOffset + 4);
buffer.writeUInt32LE(textureFilename.length, textureOffset + 8);
buffer.writeUInt32LE(textureFilenameOffset, textureOffset + 12);
textureFilename.copy(buffer, textureFilenameOffset);
const geometryFilename = Buffer.from("Spells\\FixtureGeometry.m2\0", "utf8");
buffer.writeUInt32LE(geometryFilename.length, particleOffset + 24);
buffer.writeUInt32LE(geometryFilenameOffset, particleOffset + 28);
geometryFilename.copy(buffer, geometryFilenameOffset);
buffer.writeUInt32LE(17, particleOffset);
buffer.writeUInt32LE(0x20_029, particleOffset + 4);
buffer.writeFloatLE(1, particleOffset + 8);
buffer.writeFloatLE(2, particleOffset + 12);
buffer.writeFloatLE(3, particleOffset + 16);
buffer.writeUInt16LE(4, particleOffset + 20);
buffer.writeUInt16LE(0, particleOffset + 22);
buffer.writeUInt8(2, particleOffset + 40);
buffer.writeUInt8(1, particleOffset + 41);
buffer.writeUInt16LE(7, particleOffset + 42);
buffer.writeUInt8(0, particleOffset + 44);
buffer.writeUInt8(0, particleOffset + 45);
buffer.writeUInt16LE(3, particleOffset + 46);
buffer.writeUInt16LE(4, particleOffset + 48);
buffer.writeUInt16LE(8, particleOffset + 50);
for (const relativeOffset of [52, 72, 92, 112, 132, 152, 176, 200, 220, 240, 456]) {
buffer.writeInt16LE(-1, particleOffset + relativeOffset + 2);
}
buffer.writeFloatLE(0.25, particleOffset + 172);
buffer.writeFloatLE(0.5, particleOffset + 196);
buffer.writeFloatLE(0.1, particleOffset + 308);
buffer.writeFloatLE(0.2, particleOffset + 312);
buffer.writeFloatLE(0.75, particleOffset + 348);
writeFloatTrack(52, [
{ timestamps: [0, 500], values: [3.5, 7] },
{ timestamps: [0], values: [9] },
]);
writeFloatTrack(72, [{ timestamps: [0], values: [0.125] }]);
writeFloatTrack(152, [{ timestamps: [0], values: [1.25] }]);
writeFloatTrack(176, [{ timestamps: [0, 1000], values: [4, 8] }]);
writeArray(particleOffset + 260, [0, 16_384, 32_767], 2, (offset, value) => buffer.writeUInt16LE(value, offset));
writeArray(particleOffset + 268, [[255, 128, 0], [64, 32, 16], [0, 0, 0]], 12, (offset, value) => {
value.forEach((component, index) => buffer.writeFloatLE(component, offset + index * 4));
});
writeArray(particleOffset + 276, [0, 16_384, 32_767], 2, (offset, value) => buffer.writeUInt16LE(value, offset));
writeArray(particleOffset + 284, [0, 32_767, 0], 2, (offset, value) => buffer.writeUInt16LE(value, offset));
writeArray(particleOffset + 292, [0, 16_384, 32_767], 2, (offset, value) => buffer.writeUInt16LE(value, offset));
writeArray(particleOffset + 300, [[0.25, 0.5], [1, 2], [0.5, 0.25]], 8, (offset, value) => {
value.forEach((component, index) => buffer.writeFloatLE(component, offset + index * 4));
});
return buffer;
}
function localPriestM2(filename) {
const candidates = [
path.resolve(projectRoot, "..", "LadiksMPQEditor", "common-2.mpq", "SPELLS", filename),
path.resolve(projectRoot, "..", "wow335a", "Data", "common-2.MPQ", "Spells", filename),
];
return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0];
}
test("parses numeric and string DBC fields", () => {
const table = parseDbc(tinyDbc(), "fixture");
assert.equal(table.row(0).uint(0), 42);
@@ -34,39 +162,109 @@ test("classifies paths from fixed RuneWaker ImageObject records", () => {
assert.deepEqual(records.get(573727), ["model/fx/skill/fire/act_fireball01_f_i.ros"]);
});
test("decodes supported WotLK M2 particle and ribbon records", () => {
const particleOffset = 304;
const buffer = Buffer.alloc(particleOffset + 476);
buffer.write("MD20", 0, "ascii");
buffer.writeUInt32LE(1, 296);
buffer.writeUInt32LE(particleOffset, 300);
buffer.writeUInt32LE(17, particleOffset);
buffer.writeUInt16LE(4, particleOffset + 20);
buffer.writeUInt16LE(9, particleOffset + 22);
buffer.writeUInt8(2, particleOffset + 40);
buffer.writeUInt8(1, particleOffset + 41);
buffer.writeUInt16LE(4, particleOffset + 48);
buffer.writeUInt16LE(8, particleOffset + 50);
buffer.writeUInt16LE(1, particleOffset + 56);
buffer.writeUInt32LE(3, particleOffset + 56 + 12);
const decoded = decodeWotlkM2Effects(buffer, "fixture");
assert.equal(decoded.particles[0].id, 17);
assert.equal(decoded.particles[0].textureIndex, 9);
assert.equal(decoded.particles[0].blendMode, 2);
assert.equal(decoded.particles[0].textureColumns, 8);
assert.equal(decoded.particles[0].tracks.emissionSpeed.interpolation, 1);
assert.equal(decoded.particles[0].tracks.emissionSpeed.keyCount, 3);
test("decodes WotLK MD20 particle tracks from their real record offsets", () => {
const decoded = decodeWotlkM2Effects(wotlkParticleFixture(), "fixture");
const particle = decoded.particles[0];
assert.equal(decoded.durationMs, 1500);
assert.deepEqual(decoded.animations, [
{ index: 0, id: 0, variationIndex: 0, durationMs: 1500, moveSpeed: 0, flags: 33 },
]);
assert.deepEqual(decoded.textures, [
{ type: 0, flags: 0, filename: "Spells\\FixtureGlow.blp" },
]);
assert.equal(particle.id, 17);
assert.deepEqual(particle.position, [1, 2, 3]);
assert.equal(particle.textureIndex, 0);
assert.equal(particle.geometryModel, "Spells\\FixtureGeometry.m2");
assert.equal(particle.blendMode, 2);
assert.equal(particle.emitterType, 1);
assert.equal(particle.textureColumns, 8);
assert.equal(particle.lifespanVariation, 0.25);
assert.equal(particle.emissionRateVariation, 0.5);
assert.deepEqual(particle.scaleVariation, [0.10000000149011612, 0.20000000298023224]);
assert.equal(particle.tailLength, 0.75);
assert.equal(particle.tracks.emissionSpeed.interpolation, 1);
assert.equal(particle.tracks.emissionSpeed.globalSequence, -1);
assert.equal(particle.tracks.emissionSpeed.timestamps.count, 2);
assert.deepEqual(particle.tracks.emissionSpeed.firstSequence, {
timestamps: [0, 500],
values: [3.5, 7],
samples: [{ timeMs: 0, value: 3.5 }, { timeMs: 500, value: 7 }],
});
assert.deepEqual(particle.samples.emissionRate, [
{ timeMs: 0, value: 4 },
{ timeMs: 1000, value: 8 },
]);
});
test("decodes WotLK particle color, alpha, and scale lifetime tracks", () => {
const particle = decodeWotlkM2Effects(wotlkParticleFixture(), "fixture").particles[0];
assert.equal(particle.tracks.color.kind, "particle-lifetime");
assert.deepEqual(particle.tracks.color.timestamps.values, [0, 16_384, 32_767]);
assert.deepEqual(particle.tracks.color.values.values, [[255, 128, 0], [64, 32, 16], [0, 0, 0]]);
assert.deepEqual(particle.tracks.alpha.values.values, [0, 32_767, 0]);
assert.deepEqual(particle.tracks.scale.values.values, [[0.25, 0.5], [1, 2], [0.5, 0.25]]);
assert.deepEqual(particle.samples.color[1], {
timestamp: 16_384,
normalizedTime: 16_384 / 32_767,
value: [64, 32, 16],
});
});
test("rejects WotLK M2 tracks whose nested arrays point outside the file", () => {
const buffer = wotlkParticleFixture();
buffer.writeUInt32LE(buffer.length - 4, 304 + 60);
assert.throws(
() => decodeWotlkM2Effects(buffer, "corrupt fixture"),
/emissionSpeed timestamps sequence descriptors points outside the M2 buffer/,
);
});
const flashHealM2 = localPriestM2("FlashHeal_Base.m2");
test("decodes real local WotLK Flash Heal particle descriptors", {
skip: fs.existsSync(flashHealM2) ? false : `local fixture absent: ${flashHealM2}`,
}, () => {
const decoded = decodeWotlkM2Effects(fs.readFileSync(flashHealM2), "FlashHeal_Base.m2");
assert.equal(decoded.durationMs, 3000);
assert.equal(decoded.particles.length, 5);
assert.equal(decoded.ribbons.length, 0);
assert.equal(decoded.textures[0].filename, "SPELLS\\STAR5A.BLP");
const particle = decoded.particles[0];
assert.equal(particle.textureIndex, 0);
assert.equal(particle.blendMode, 4);
assert.equal(particle.emitterType, 1);
assert.deepEqual(particle.samples.lifespan, [{ timeMs: 0, value: 1 }]);
assert.deepEqual(particle.samples.emissionRate, [{ timeMs: 0, value: 5 }]);
assert.ok(Math.abs(particle.samples.emissionSpeed[0].value - 3.3333332538604736) < 1e-6);
assert.deepEqual(particle.tracks.color.timestamps.values, [0, 16_384, 32_767]);
assert.deepEqual(particle.tracks.alpha.values.values, [0, 32_767, 0]);
assert.deepEqual(particle.tracks.color.values.values[0], [255, 156, 0]);
});
const holySmiteM2 = localPriestM2("HolySmite_Low_Chest.m2");
test("decodes real local WotLK Holy Smite particles and ribbons", {
skip: fs.existsSync(holySmiteM2) ? false : `local fixture absent: ${holySmiteM2}`,
}, () => {
const decoded = decodeWotlkM2Effects(fs.readFileSync(holySmiteM2), "HolySmite_Low_Chest.m2");
assert.equal(decoded.durationMs, 1133);
assert.equal(decoded.particles.length, 13);
assert.equal(decoded.ribbons.length, 2);
assert.equal(decoded.textures.length, 8);
assert.equal(decoded.textures[0].filename, "Spells\\GlowStar_Yellow.blp");
assert.deepEqual(decoded.ribbons[0].textureIndices.values, [6]);
assert.deepEqual(decoded.ribbons[0].materialIndices.values, [0]);
assert.equal(decoded.ribbons[0].edgesPerSecond, 80);
assert.ok(decoded.ribbons[0].tracks.visibility.firstSequence.samples.length > 0);
});
test("generated coverage packages every referenced source sound", () => {
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const manifest = JSON.parse(fs.readFileSync(path.join(root, "public", "assets", "game", "combat-effects", "manifest.json"), "utf8"));
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "public", "assets", "game", "combat-effects", "manifest.json"), "utf8"));
assert.equal(manifest.coverage.requested, manifest.coverage.authentic + manifest.coverage.fallback);
assert.deepEqual(manifest.coverage.audio.missing, []);
const sounds = manifest.entries.flatMap((entry) => entry.sounds ?? []);
assert.ok(sounds.length > 0);
for (const sound of sounds) {
assert.ok(sound.url?.startsWith("/assets/game/combat-effects/audio/"));
assert.ok(fs.existsSync(path.join(root, "public", sound.url)));
assert.ok(fs.existsSync(path.join(projectRoot, "public", sound.url)));
}
});
@@ -0,0 +1,534 @@
#!/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}.`);
}
@@ -0,0 +1,230 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
auditPriestOriginalManifest,
packagePriestOriginalEffects,
priestSpellIdsFromCatalog,
} from "./package-priest-original.mjs";
test("collects every ranked Priest spell id", () => {
const ids = priestSpellIdsFromCatalog({
classes: {
priest: [
{ ranks: [{ spellId: 2050 }, { spellId: 2052 }] },
{ ranks: [{ spellId: 2061 }] },
],
},
});
assert.equal(ids.has(2050), true);
assert.equal(ids.has(2052), true);
assert.equal(ids.has(2061), true);
assert.equal(ids.has(585), true);
});
test("packages source-backed Priest graphs and attaches only ready assets", () => {
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "healer-man-priest-effects-"));
try {
const modelRoot = path.join(temporaryRoot, "models");
const textureRoot = path.join(temporaryRoot, "textures");
const outputRoot = path.join(temporaryRoot, "public-pack");
fs.mkdirSync(path.join(modelRoot, "Spells"), { recursive: true });
fs.mkdirSync(path.join(textureRoot, "Spells"), { recursive: true });
const modelBuffer = Buffer.alloc(64);
modelBuffer.writeUInt32LE(0, 60);
fs.writeFileSync(path.join(modelRoot, "Spells", "Fixture.m2"), modelBuffer);
fs.writeFileSync(path.join(textureRoot, "Spells", "Fixture.BLP"), Buffer.from("source-texture"));
const sourceManifest = {
schemaVersion: 1,
coverage: { requested: 1, authentic: 1, fallback: 0 },
entries: [{
key: "wow335:2061",
source: "wow335",
spellId: 2061,
phases: { impact: [{ model: "Spells/Fixture.mdx" }] },
}],
};
const decoded = {
durationMs: 900,
animations: [{ index: 0, id: 0, variationIndex: 0, durationMs: 900, moveSpeed: 0, flags: 0 }],
textures: [{ type: 0, flags: 0, filename: "Spells/Fixture.BLP" }],
ribbons: [],
particles: [{
id: 1,
flags: 0,
position: [0, 0, 0],
parentBone: 0,
textureIndex: 0,
geometryModel: "",
recursionModel: "",
blendMode: 4,
emitterType: 1,
particleColorIndex: 0,
particleType: 0,
headOrTail: 0,
textureTileRotation: 0,
textureRows: 1,
textureColumns: 1,
lifespanVariation: 0,
emissionRateVariation: 0,
scaleVariation: [0, 0],
tailLength: 0,
tracks: Object.fromEntries([
"emissionSpeed", "speedVariation", "verticalRange", "horizontalRange", "gravity",
"lifespan", "emissionRate", "emissionAreaLength", "emissionAreaWidth", "zSource", "enabled",
].map((name) => [name, {
interpolation: name === "emissionRate" ? 1 : 0,
globalSequence: -1,
firstSequence: { samples: name === "enabled" ? [{ timeMs: 0, value: 1 }] : [] },
}]).concat([
["headCell", { samples: [] }],
["tailCell", { samples: [] }],
])),
samples: {
emissionRate: [{ timeMs: 0, value: 12 }],
lifespan: [{ timeMs: 0, value: 0.8 }],
emissionSpeed: [{ timeMs: 0, value: 1 }],
color: [{ normalizedTime: 0, value: [255, 255, 255] }],
alpha: [{ normalizedTime: 0, value: 32767 }],
scale: [{ normalizedTime: 0, value: [0.2, 0.2] }],
},
}],
};
const { manifest, report } = packagePriestOriginalEffects(sourceManifest, {
catalog: { classes: { priest: [{ ranks: [{ spellId: 2061 }] }] } },
modelRoots: [modelRoot],
textureRoots: [textureRoot],
outputRoot,
publicPrefix: "/fixture/priest",
decodeM2: () => decoded,
convertTexture: (source, output) => fs.copyFileSync(source, output),
});
const asset = manifest.entries[0].phases.impact[0];
assert.equal(asset.effectKind, "wow-m2-particles");
assert.match(asset.effectGraphUrl, /^\/fixture\/priest\/graphs\/fixture-/);
assert.equal(report.packagedModels, 1);
assert.equal(report.readyParticleEmitters, 1);
const graphFile = path.join(outputRoot, "graphs", path.posix.basename(asset.effectGraphUrl));
const graph = JSON.parse(fs.readFileSync(graphFile, "utf8"));
assert.equal(graph.sourceModel, "Spells/Fixture.mdx");
assert.equal(graph.textures[0].url.startsWith("/fixture/priest/textures/"), true);
assert.equal(graph.interpolation, undefined);
assert.equal(graph.particles[0].interpolation.emissionRate, 1);
assert.deepEqual(graph.particles[0].samples.enabled, [{ timeMs: 0, value: 1 }]);
} finally {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
});
test("packages the Power Word: Shield aura as a three-layer static mesh graph", () => {
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "healer-man-priest-shield-"));
try {
const modelRoot = path.join(temporaryRoot, "models");
const textureRoot = path.join(temporaryRoot, "textures");
const outputRoot = path.join(temporaryRoot, "public-pack");
fs.mkdirSync(path.join(modelRoot, "Spells"), { recursive: true });
fs.mkdirSync(path.join(textureRoot, "Spells"), { recursive: true });
fs.writeFileSync(
path.join(modelRoot, "Spells", "HolyDivineShield_State_Base.m2"), Buffer.from("fixture-m2"),
);
fs.writeFileSync(
path.join(modelRoot, "Spells", "HolyDivineShield_State_Base00.skin"), Buffer.from("fixture-skin"),
);
for (const texture of ["ShieldA.BLP", "ShieldB.BLP", "ShieldC.BLP"]) {
fs.writeFileSync(path.join(textureRoot, "Spells", texture), Buffer.from(`source-${texture}`));
}
const sourceManifest = {
schemaVersion: 1,
coverage: { requested: 1, authentic: 1, fallback: 0 },
entries: [{
key: "wow335:17",
source: "wow335",
spellId: 17,
phases: {
"aura-start": [{ model: "Spells/HolyDivineShield_State_Base.mdx" }],
},
}],
};
const decodedMesh = {
bounds: { minimum: [-1, -1, 0], maximum: [1, 1, 0], radius: 1.5 },
vertices: [
{ position: [0, 0, 0], normal: [1, 0, 0], uv: [0, 0] },
{ position: [0, 1, 0], normal: [1, 0, 0], uv: [1, 0] },
{ position: [0, 0, 1], normal: [1, 0, 0], uv: [0, 1] },
],
indices: [0, 1, 2, 0, 1, 2, 0, 1, 2],
textures: ["ShieldA.BLP", "ShieldB.BLP", "ShieldC.BLP"].map((filename) => ({
type: 0, flags: 0, filename: `Spells/${filename}`,
})),
materials: [0, 1, 2].map(() => ({ flags: 19, blendMode: 4 })),
skin: {
textureUnits: [0, 1, 2].map((index) => ({
submeshIndex: index,
priorityPlane: 0,
shaderId: 0,
colorIndex: index,
materialIndex: index,
materialLayer: 0,
textureIndices: [index],
textureCoordinateIndices: [0],
textureWeightIndices: [index],
textureTransformIndices: [-1],
drawRange: { indexStart: index * 3, indexCount: 3 },
})),
},
};
const { manifest, report } = packagePriestOriginalEffects(sourceManifest, {
catalog: { classes: { priest: [{ ranks: [{ spellId: 17 }] }] } },
modelRoots: [modelRoot],
textureRoots: [textureRoot],
outputRoot,
publicPrefix: "/fixture/priest",
decodeM2: () => { throw new Error("particle decoder should not handle the static shield"); },
decodeMesh: () => decodedMesh,
convertTexture: (source, output) => fs.copyFileSync(source, output),
});
const asset = manifest.entries[0].phases["aura-start"][0];
assert.equal(asset.effectKind, "wow-m2-static-mesh");
assert.match(asset.effectGraphUrl, /^\/fixture\/priest\/graphs\/holydivineshield-state-base-/);
assert.equal(report.readyStaticMeshes, 1);
assert.equal(report.readyParticleEmitters, 0);
const graphFile = path.join(outputRoot, "graphs", path.posix.basename(asset.effectGraphUrl));
const graph = JSON.parse(fs.readFileSync(graphFile, "utf8"));
assert.equal(graph.kind, "wow-m2-static-mesh");
assert.deepEqual(graph.geometry, {
positions: [0, 0, 0, 0, 1, 0, 0, 0, 1],
normals: [1, 0, 0, 1, 0, 0, 1, 0, 0],
uvs: [0, 0, 1, 0, 0, 1],
indices: [0, 1, 2, 0, 1, 2, 0, 1, 2],
});
assert.equal(graph.layers.length, 3);
assert.deepEqual(graph.layers.map((layer) => ({
textureIndex: layer.textureIndex,
blendMode: layer.blendMode,
drawRange: [layer.indexStart, layer.indexCount],
})), [
{ textureIndex: 0, blendMode: 4, drawRange: [0, 3] },
{ textureIndex: 1, blendMode: 4, drawRange: [3, 3] },
{ textureIndex: 2, blendMode: 4, drawRange: [6, 3] },
]);
assert.ok(graph.textures.every((texture) => texture.url.startsWith("/fixture/priest/textures/")));
const audit = auditPriestOriginalManifest(manifest, new Set([17]), new Set([17]));
assert.deepEqual(audit.missingPowerWordShieldAuraStarts, []);
const missingAudit = auditPriestOriginalManifest({
...manifest,
entries: manifest.entries.map((entry) => ({
...entry,
phases: { "aura-start": entry.phases["aura-start"].map((item) => ({ ...item, effectKind: "wrong" })) },
})),
}, new Set([17]), new Set([17]));
assert.deepEqual(missingAudit.missingPowerWordShieldAuraStarts, [17]);
} finally {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
});
+297
View File
@@ -0,0 +1,297 @@
const M2_HEADER_SIZE = 304;
const M2_VERTEX_SIZE = 48;
const M2_TEXTURE_SIZE = 16;
const M2_RENDER_FLAG_SIZE = 4;
const SKIN_HEADER_SIZE = 48;
const SKIN_SUBMESH_SIZE = 48;
const SKIN_TEXTURE_UNIT_SIZE = 24;
const VALUE_TYPES = {
uint8x4: {
byteLength: 4,
read: (buffer, offset) => Array.from(buffer.subarray(offset, offset + 4)),
},
uint16: { byteLength: 2, read: (buffer, offset) => buffer.readUInt16LE(offset) },
int16: { byteLength: 2, read: (buffer, offset) => buffer.readInt16LE(offset) },
};
function asBuffer(value) {
return Buffer.isBuffer(value) ? value : Buffer.from(value);
}
function assertRange(buffer, offset, byteLength, label) {
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(byteLength)
|| offset < 0 || byteLength < 0 || offset > buffer.length - byteLength) {
throw new Error(`${label} points outside its source buffer.`);
}
}
function readArrayHeader(buffer, offset, label) {
assertRange(buffer, offset, 8, `${label} descriptor`);
return { count: buffer.readUInt32LE(offset), offset: buffer.readUInt32LE(offset + 4) };
}
function decodeArray(buffer, descriptorOffset, valueType, label) {
const descriptor = readArrayHeader(buffer, descriptorOffset, label);
const byteLength = descriptor.count * valueType.byteLength;
if (!Number.isSafeInteger(byteLength)) throw new Error(`${label} is too large to decode.`);
if (descriptor.count) assertRange(buffer, descriptor.offset, byteLength, label);
return {
...descriptor,
values: Array.from({ length: descriptor.count }, (_, index) => (
valueType.read(buffer, descriptor.offset + index * valueType.byteLength)
)),
};
}
function decodeString(buffer, descriptorOffset, label) {
const descriptor = readArrayHeader(buffer, descriptorOffset, label);
if (!descriptor.count) return "";
assertRange(buffer, descriptor.offset, descriptor.count, label);
const bytes = buffer.subarray(descriptor.offset, descriptor.offset + descriptor.count);
const terminator = bytes.indexOf(0);
return bytes.toString("utf8", 0, terminator < 0 ? bytes.length : terminator);
}
function vector2(buffer, offset) {
return [buffer.readFloatLE(offset), buffer.readFloatLE(offset + 4)];
}
function vector3(buffer, offset) {
return [buffer.readFloatLE(offset), buffer.readFloatLE(offset + 4), buffer.readFloatLE(offset + 8)];
}
function decodeVertices(buffer, name) {
const count = buffer.readUInt32LE(60);
const offset = buffer.readUInt32LE(64);
const byteLength = count * M2_VERTEX_SIZE;
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} vertices are too large to decode.`);
if (count) assertRange(buffer, offset, byteLength, `${name} vertices`);
return Array.from({ length: count }, (_, index) => {
const vertexOffset = offset + index * M2_VERTEX_SIZE;
const boneWeights = VALUE_TYPES.uint8x4.read(buffer, vertexOffset + 12);
return {
position: vector3(buffer, vertexOffset),
boneWeights,
normalizedBoneWeights: boneWeights.map((weight) => weight / 255),
boneIndices: VALUE_TYPES.uint8x4.read(buffer, vertexOffset + 16),
normal: vector3(buffer, vertexOffset + 20),
uv: vector2(buffer, vertexOffset + 32),
uv2: vector2(buffer, vertexOffset + 40),
};
});
}
function decodeTextures(buffer, name) {
const count = buffer.readUInt32LE(80);
const offset = buffer.readUInt32LE(84);
const byteLength = count * M2_TEXTURE_SIZE;
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} textures are too large to decode.`);
if (count) assertRange(buffer, offset, byteLength, `${name} texture records`);
return Array.from({ length: count }, (_, index) => {
const textureOffset = offset + index * M2_TEXTURE_SIZE;
return {
type: buffer.readUInt32LE(textureOffset),
flags: buffer.readUInt32LE(textureOffset + 4),
filename: decodeString(buffer, textureOffset + 8, `${name} texture ${index} filename`),
};
});
}
function decodeMaterials(buffer, name) {
const count = buffer.readUInt32LE(112);
const offset = buffer.readUInt32LE(116);
const byteLength = count * M2_RENDER_FLAG_SIZE;
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} materials are too large to decode.`);
if (count) assertRange(buffer, offset, byteLength, `${name} material records`);
return Array.from({ length: count }, (_, index) => {
const materialOffset = offset + index * M2_RENDER_FLAG_SIZE;
return {
flags: buffer.readUInt16LE(materialOffset),
blendMode: buffer.readUInt16LE(materialOffset + 2),
};
});
}
function decodeSkinSubmeshes(buffer, count, offset, triangleCount, vertexLookupCount, name) {
const byteLength = count * SKIN_SUBMESH_SIZE;
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} submeshes are too large to decode.`);
if (count) assertRange(buffer, offset, byteLength, `${name} submesh records`);
return Array.from({ length: count }, (_, index) => {
const submeshOffset = offset + index * SKIN_SUBMESH_SIZE;
const vertexStart = buffer.readUInt16LE(submeshOffset + 4);
const vertexCount = buffer.readUInt16LE(submeshOffset + 6);
const indexStart = buffer.readUInt16LE(submeshOffset + 8);
const indexCount = buffer.readUInt16LE(submeshOffset + 10);
if (vertexStart + vertexCount > vertexLookupCount) {
throw new Error(`${name} submesh ${index} vertex range exceeds the vertex lookup.`);
}
if (indexStart + indexCount > triangleCount) {
throw new Error(`${name} submesh ${index} index range exceeds the triangle lookup.`);
}
return {
id: buffer.readUInt16LE(submeshOffset),
level: buffer.readUInt16LE(submeshOffset + 2),
vertexStart,
vertexCount,
indexStart,
indexCount,
boneCount: buffer.readUInt16LE(submeshOffset + 12),
boneComboIndex: buffer.readUInt16LE(submeshOffset + 14),
boneInfluences: buffer.readUInt16LE(submeshOffset + 16),
centerBoneIndex: buffer.readUInt16LE(submeshOffset + 18),
center: vector3(buffer, submeshOffset + 20),
sortCenter: vector3(buffer, submeshOffset + 32),
sortRadius: buffer.readFloatLE(submeshOffset + 44),
};
});
}
function lookupSlice(lookup, start, count, label) {
if (start + count > lookup.length) throw new Error(`${label} exceeds its M2 lookup table.`);
return lookup.slice(start, start + count);
}
function decodeSkinTextureUnits(buffer, count, offset, context, name) {
const byteLength = count * SKIN_TEXTURE_UNIT_SIZE;
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} texture units are too large to decode.`);
if (count) assertRange(buffer, offset, byteLength, `${name} texture unit records`);
return Array.from({ length: count }, (_, index) => {
const unitOffset = offset + index * SKIN_TEXTURE_UNIT_SIZE;
const submeshIndex = buffer.readUInt16LE(unitOffset + 4);
const materialIndex = buffer.readUInt16LE(unitOffset + 10);
const textureCount = buffer.readUInt16LE(unitOffset + 14);
const textureComboIndex = buffer.readUInt16LE(unitOffset + 16);
const textureCoordinateComboIndex = buffer.readUInt16LE(unitOffset + 18);
const textureWeightComboIndex = buffer.readUInt16LE(unitOffset + 20);
const textureTransformComboIndex = buffer.readUInt16LE(unitOffset + 22);
if (submeshIndex >= context.submeshes.length) {
throw new Error(`${name} texture unit ${index} references missing submesh ${submeshIndex}.`);
}
if (materialIndex >= context.materials.length) {
throw new Error(`${name} texture unit ${index} references missing material ${materialIndex}.`);
}
const textureIndices = lookupSlice(
context.lookups.textures, textureComboIndex, textureCount, `${name} texture unit ${index} textures`,
);
for (const textureIndex of textureIndices) {
if (textureIndex >= context.textures.length) {
throw new Error(`${name} texture unit ${index} references missing texture ${textureIndex}.`);
}
}
const submesh = context.submeshes[submeshIndex];
return {
flags: buffer.readUInt8(unitOffset),
priorityPlane: buffer.readInt8(unitOffset + 1),
shaderId: buffer.readUInt16LE(unitOffset + 2),
submeshIndex,
geosetIndex: buffer.readUInt16LE(unitOffset + 6),
colorIndex: buffer.readInt16LE(unitOffset + 8),
materialIndex,
materialLayer: buffer.readUInt16LE(unitOffset + 12),
textureCount,
textureComboIndex,
textureCoordinateComboIndex,
textureWeightComboIndex,
textureTransformComboIndex,
textureIndices,
textureFilenames: textureIndices.map((textureIndex) => context.textures[textureIndex].filename),
textureCoordinateIndices: lookupSlice(
context.lookups.textureCoordinates,
textureCoordinateComboIndex,
textureCount,
`${name} texture unit ${index} texture coordinates`,
),
textureWeightIndices: lookupSlice(
context.lookups.textureWeights,
textureWeightComboIndex,
textureCount,
`${name} texture unit ${index} texture weights`,
),
textureTransformIndices: lookupSlice(
context.lookups.textureTransforms,
textureTransformComboIndex,
textureCount,
`${name} texture unit ${index} texture transforms`,
),
drawRange: { indexStart: submesh.indexStart, indexCount: submesh.indexCount },
};
});
}
/** Decode a browser-neutral static mesh descriptor from WotLK 3.3.5 MD20 and SKIN data. */
export function decodeWotlkM2Mesh(m2Source, skinSource, name = "M2 mesh") {
const m2 = asBuffer(m2Source);
const skin = asBuffer(skinSource);
if (m2.length < M2_HEADER_SIZE || m2.toString("ascii", 0, 4) !== "MD20" || m2.readUInt32LE(4) !== 264) {
throw new Error(`${name} is not a supported WotLK 3.3.5 MD20.`);
}
if (skin.length < SKIN_HEADER_SIZE || skin.toString("ascii", 0, 4) !== "SKIN") {
throw new Error(`${name} has no supported WotLK SKIN profile.`);
}
const vertices = decodeVertices(m2, name);
const textures = decodeTextures(m2, name);
const materials = decodeMaterials(m2, name);
const lookups = {
textures: decodeArray(m2, 128, VALUE_TYPES.uint16, `${name} texture lookup`).values,
textureCoordinates: decodeArray(m2, 136, VALUE_TYPES.int16, `${name} texture-coordinate lookup`).values,
textureWeights: decodeArray(m2, 144, VALUE_TYPES.uint16, `${name} texture-weight lookup`).values,
textureTransforms: decodeArray(m2, 152, VALUE_TYPES.int16, `${name} texture-transform lookup`).values,
};
const vertexLookup = decodeArray(skin, 4, VALUE_TYPES.uint16, `${name} SKIN vertex lookup`).values;
const triangleLookup = decodeArray(skin, 12, VALUE_TYPES.uint16, `${name} SKIN triangle lookup`).values;
const boneIndices = decodeArray(skin, 20, VALUE_TYPES.uint8x4, `${name} SKIN bone indices`).values;
if (triangleLookup.length % 3 !== 0) throw new Error(`${name} SKIN triangle lookup is not divisible by three.`);
if (boneIndices.length !== vertexLookup.length) {
throw new Error(`${name} SKIN bone-index count does not match its vertex lookup.`);
}
const indices = triangleLookup.map((lookupIndex, index) => {
if (lookupIndex >= vertexLookup.length) {
throw new Error(`${name} SKIN triangle index ${index} exceeds its vertex lookup.`);
}
const vertexIndex = vertexLookup[lookupIndex];
if (vertexIndex >= vertices.length) {
throw new Error(`${name} SKIN vertex lookup ${lookupIndex} references missing M2 vertex ${vertexIndex}.`);
}
return vertexIndex;
});
const submeshCount = skin.readUInt32LE(28);
const submeshOffset = skin.readUInt32LE(32);
const submeshes = decodeSkinSubmeshes(
skin, submeshCount, submeshOffset, triangleLookup.length, vertexLookup.length, name,
).map((submesh) => ({
...submesh,
indices: indices.slice(submesh.indexStart, submesh.indexStart + submesh.indexCount),
}));
const textureUnitCount = skin.readUInt32LE(36);
const textureUnitOffset = skin.readUInt32LE(40);
const textureUnits = decodeSkinTextureUnits(skin, textureUnitCount, textureUnitOffset, {
submeshes, materials, textures, lookups,
}, name);
return {
magic: "MD20",
version: 264,
modelName: decodeString(m2, 8, `${name} model name`),
bounds: {
minimum: vector3(m2, 160),
maximum: vector3(m2, 172),
radius: m2.readFloatLE(184),
},
vertices,
indices,
textures,
materials,
lookups,
skin: {
magic: "SKIN",
maximumBones: skin.readUInt32LE(44),
vertexLookup,
triangleLookup,
boneIndices,
submeshes,
textureUnits,
},
};
}
@@ -0,0 +1,206 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { decodeWotlkM2Mesh } from "./wotlk-m2-mesh.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
function syntheticM2AndSkin() {
const m2 = Buffer.alloc(640);
const skin = Buffer.alloc(144);
const vertexOffset = 304;
const materialOffset = 448;
const textureLookupOffset = 452;
const textureCoordinateLookupOffset = 454;
const textureWeightLookupOffset = 456;
const textureTransformLookupOffset = 458;
const modelNameOffset = 464;
const textureOffset = 496;
const textureFilenameOffset = 512;
m2.write("MD20", 0, "ascii");
m2.writeUInt32LE(264, 4);
const modelName = Buffer.from("SyntheticShield\0", "utf8");
m2.writeUInt32LE(modelName.length, 8);
m2.writeUInt32LE(modelNameOffset, 12);
modelName.copy(m2, modelNameOffset);
m2.writeUInt32LE(3, 60);
m2.writeUInt32LE(vertexOffset, 64);
m2.writeUInt32LE(1, 80);
m2.writeUInt32LE(textureOffset, 84);
m2.writeUInt32LE(1, 112);
m2.writeUInt32LE(materialOffset, 116);
m2.writeUInt32LE(1, 128);
m2.writeUInt32LE(textureLookupOffset, 132);
m2.writeUInt32LE(1, 136);
m2.writeUInt32LE(textureCoordinateLookupOffset, 140);
m2.writeUInt32LE(1, 144);
m2.writeUInt32LE(textureWeightLookupOffset, 148);
m2.writeUInt32LE(1, 152);
m2.writeUInt32LE(textureTransformLookupOffset, 156);
[-1, -2, -3].forEach((value, index) => m2.writeFloatLE(value, 160 + index * 4));
[1, 2, 3].forEach((value, index) => m2.writeFloatLE(value, 172 + index * 4));
m2.writeFloatLE(4, 184);
const vertices = [
{ position: [0, 0, 0], normal: [0, 0, 1], uv: [0, 0] },
{ position: [1, 0, 0], normal: [0, 0, 1], uv: [1, 0] },
{ position: [0, 1, 0], normal: [0, 0, 1], uv: [0, 1] },
];
vertices.forEach((vertex, index) => {
const offset = vertexOffset + index * 48;
vertex.position.forEach((value, component) => m2.writeFloatLE(value, offset + component * 4));
m2.writeUInt8(255, offset + 12);
m2.writeUInt8(2, offset + 16);
vertex.normal.forEach((value, component) => m2.writeFloatLE(value, offset + 20 + component * 4));
vertex.uv.forEach((value, component) => m2.writeFloatLE(value, offset + 32 + component * 4));
vertex.uv.forEach((value, component) => m2.writeFloatLE(value, offset + 40 + component * 4));
});
m2.writeUInt16LE(19, materialOffset);
m2.writeUInt16LE(4, materialOffset + 2);
m2.writeUInt16LE(0, textureLookupOffset);
m2.writeInt16LE(0, textureCoordinateLookupOffset);
m2.writeUInt16LE(0, textureWeightLookupOffset);
m2.writeInt16LE(-1, textureTransformLookupOffset);
const textureFilename = Buffer.from("SPELLS\\SYNTHETIC.BLP\0", "utf8");
m2.writeUInt32LE(0, textureOffset);
m2.writeUInt32LE(0, textureOffset + 4);
m2.writeUInt32LE(textureFilename.length, textureOffset + 8);
m2.writeUInt32LE(textureFilenameOffset, textureOffset + 12);
textureFilename.copy(m2, textureFilenameOffset);
skin.write("SKIN", 0, "ascii");
skin.writeUInt32LE(3, 4);
skin.writeUInt32LE(48, 8);
skin.writeUInt32LE(3, 12);
skin.writeUInt32LE(54, 16);
skin.writeUInt32LE(3, 20);
skin.writeUInt32LE(60, 24);
skin.writeUInt32LE(1, 28);
skin.writeUInt32LE(72, 32);
skin.writeUInt32LE(1, 36);
skin.writeUInt32LE(120, 40);
skin.writeUInt32LE(1, 44);
[0, 1, 2].forEach((value, index) => skin.writeUInt16LE(value, 48 + index * 2));
[0, 1, 2].forEach((value, index) => skin.writeUInt16LE(value, 54 + index * 2));
for (let index = 0; index < 3; index += 1) skin.writeUInt8(2, 60 + index * 4);
const submeshOffset = 72;
skin.writeUInt16LE(0, submeshOffset);
skin.writeUInt16LE(0, submeshOffset + 2);
skin.writeUInt16LE(0, submeshOffset + 4);
skin.writeUInt16LE(3, submeshOffset + 6);
skin.writeUInt16LE(0, submeshOffset + 8);
skin.writeUInt16LE(3, submeshOffset + 10);
skin.writeUInt16LE(1, submeshOffset + 12);
skin.writeUInt16LE(0, submeshOffset + 14);
skin.writeUInt16LE(1, submeshOffset + 16);
skin.writeUInt16LE(2, submeshOffset + 18);
[0.5, 0.5, 0].forEach((value, index) => skin.writeFloatLE(value, submeshOffset + 20 + index * 4));
[0.5, 0.5, 0].forEach((value, index) => skin.writeFloatLE(value, submeshOffset + 32 + index * 4));
skin.writeFloatLE(1, submeshOffset + 44);
const unitOffset = 120;
skin.writeUInt8(8, unitOffset);
skin.writeInt8(0, unitOffset + 1);
skin.writeUInt16LE(0, unitOffset + 2);
skin.writeUInt16LE(0, unitOffset + 4);
skin.writeUInt16LE(0, unitOffset + 6);
skin.writeInt16LE(-1, unitOffset + 8);
skin.writeUInt16LE(0, unitOffset + 10);
skin.writeUInt16LE(0, unitOffset + 12);
skin.writeUInt16LE(1, unitOffset + 14);
skin.writeUInt16LE(0, unitOffset + 16);
skin.writeUInt16LE(0, unitOffset + 18);
skin.writeUInt16LE(0, unitOffset + 20);
skin.writeUInt16LE(0, unitOffset + 22);
return { m2, skin };
}
test("decodes browser-neutral M2 vertices and resolved SKIN draw data", () => {
const { m2, skin } = syntheticM2AndSkin();
const decoded = decodeWotlkM2Mesh(m2, skin, "synthetic shield");
assert.equal(decoded.modelName, "SyntheticShield");
assert.deepEqual(decoded.bounds, { minimum: [-1, -2, -3], maximum: [1, 2, 3], radius: 4 });
assert.deepEqual(decoded.vertices[1], {
position: [1, 0, 0],
boneWeights: [255, 0, 0, 0],
normalizedBoneWeights: [1, 0, 0, 0],
boneIndices: [2, 0, 0, 0],
normal: [0, 0, 1],
uv: [1, 0],
uv2: [1, 0],
});
assert.deepEqual(decoded.indices, [0, 1, 2]);
assert.deepEqual(decoded.textures, [{ type: 0, flags: 0, filename: "SPELLS\\SYNTHETIC.BLP" }]);
assert.deepEqual(decoded.materials, [{ flags: 19, blendMode: 4 }]);
assert.deepEqual(decoded.skin.submeshes[0].indices, [0, 1, 2]);
assert.deepEqual(decoded.skin.textureUnits[0], {
flags: 8,
priorityPlane: 0,
shaderId: 0,
submeshIndex: 0,
geosetIndex: 0,
colorIndex: -1,
materialIndex: 0,
materialLayer: 0,
textureCount: 1,
textureComboIndex: 0,
textureCoordinateComboIndex: 0,
textureWeightComboIndex: 0,
textureTransformComboIndex: 0,
textureIndices: [0],
textureFilenames: ["SPELLS\\SYNTHETIC.BLP"],
textureCoordinateIndices: [0],
textureWeightIndices: [0],
textureTransformIndices: [-1],
drawRange: { indexStart: 0, indexCount: 3 },
});
});
test("rejects SKIN triangle references outside the vertex lookup", () => {
const { m2, skin } = syntheticM2AndSkin();
skin.writeUInt16LE(3, 54 + 4);
assert.throws(
() => decodeWotlkM2Mesh(m2, skin, "corrupt shield"),
/SKIN triangle index 2 exceeds its vertex lookup/,
);
});
const localM2 = path.resolve(
projectRoot, "..", "LadiksMPQEditor", "common-2.mpq", "SPELLS", "HolyDivineShield_State_Base.m2",
);
const localSkin = path.resolve(
projectRoot, "..", "LadiksMPQEditor", "common-2.mpq", "SPELLS", "HolyDivineShield_State_Base00.skin",
);
const hasLocalShield = fs.existsSync(localM2) && fs.existsSync(localSkin);
test("decodes the real local Power Word: Shield static mesh", {
skip: hasLocalShield ? false : `local shield files absent: ${localM2}`,
}, () => {
const decoded = decodeWotlkM2Mesh(
fs.readFileSync(localM2), fs.readFileSync(localSkin), "HolyDivineShield_State_Base",
);
assert.equal(decoded.modelName, "HolyDivineShield_State_Base");
assert.equal(decoded.vertices.length, 12);
assert.equal(decoded.indices.length, 18);
assert.equal(decoded.skin.submeshes.length, 3);
assert.equal(decoded.skin.textureUnits.length, 3);
assert.deepEqual(decoded.textures.map((texture) => texture.filename), [
"SPELLS\\SHOCKWAVE10D.BLP",
"SPELLS\\GENERICGLOW2B1.BLP",
"SPELLS\\SHOCKWAVE10B.BLP",
]);
assert.ok(decoded.materials.every((material) => material.flags === 19 && material.blendMode === 4));
assert.ok(decoded.skin.submeshes.every((submesh) => submesh.indexCount === 6));
assert.deepEqual(decoded.skin.textureUnits.map((unit) => unit.textureIndices), [[0], [1], [2]]);
assert.deepEqual(decoded.skin.textureUnits.map((unit) => unit.drawRange), [
{ indexStart: 0, indexCount: 6 },
{ indexStart: 6, indexCount: 6 },
{ indexStart: 12, indexCount: 6 },
]);
assert.deepEqual([...new Set(decoded.vertices.map((vertex) => vertex.boneIndices[0]))], [1, 2, 3]);
assert.ok(decoded.vertices.every((vertex) => vertex.boneWeights[0] === 255));
});