forked from phenom/healer-man
Release Healer Man 0.1.12
This commit is contained in:
1 parent
13970d03c8
commit
65e24f644e
201 files changed
+5061
-123
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
Reference in new issue
Block a user