Files
healer-man/scripts/combat-effects/wotlk-m2-mesh.mjs
2026-09-11 18:49:33 -04:00

448 lines
19 KiB
JavaScript

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 = {
floatQuaternion: {
byteLength: 16,
read: (buffer, offset) => [0, 4, 8, 12].map((component) => buffer.readFloatLE(offset + component)),
},
quaternion: {
byteLength: 8,
read: (buffer, offset) => [0, 2, 4, 6].map((component) => {
const value = buffer.readInt16LE(offset + component);
return (value < 0 ? value + 32768 : value - 32767) / 32767;
}),
},
uint8x4: {
byteLength: 4,
read: (buffer, offset) => Array.from(buffer.subarray(offset, offset + 4)),
},
uint32: { byteLength: 4, read: (buffer, offset) => buffer.readUInt32LE(offset) },
uint16: { byteLength: 2, read: (buffer, offset) => buffer.readUInt16LE(offset) },
int16: { byteLength: 2, read: (buffer, offset) => buffer.readInt16LE(offset) },
vector3: { byteLength: 12, read: (buffer, offset) => vector3(buffer, 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 decodeNestedArray(buffer, descriptorOffset, valueType, label) {
const descriptor = readArrayHeader(buffer, descriptorOffset, label);
const byteLength = descriptor.count * 8;
if (!Number.isSafeInteger(byteLength)) throw new Error(`${label} is too large to decode.`);
if (descriptor.count) assertRange(buffer, descriptor.offset, byteLength, `${label} sequence descriptors`);
return {
...descriptor,
sequences: Array.from({ length: descriptor.count }, (_, index) => (
decodeArray(buffer, descriptor.offset + index * 8, valueType, `${label} sequence ${index}`)
)),
};
}
function decodeTrack(buffer, offset, valueType, label) {
assertRange(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 = decodeNestedArray(buffer, offset + 4, VALUE_TYPES.uint32, `${label} timestamps`);
const values = decodeNestedArray(buffer, offset + 12, trackValueType, `${label} values`);
if (timestamps.sequences.length !== values.sequences.length) {
throw new Error(`${label} timestamp and value sequence counts do not match.`);
}
const sequences = timestamps.sequences.map((timestampSequence, index) => {
const valueSequence = values.sequences[index];
if (timestampSequence.values.length !== valueSequence.values.length) {
throw new Error(`${label} sequence ${index} timestamp and value counts do not match.`);
}
return {
timestamps: timestampSequence.values,
values: valueSequence.values,
};
});
return {
interpolation,
globalSequence: buffer.readInt16LE(offset + 2),
sequences,
};
}
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 decodeAnimations(buffer, name) {
const count = buffer.readUInt32LE(28);
const offset = buffer.readUInt32LE(32);
const recordSize = 64;
const byteLength = count * recordSize;
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} animations are too large to decode.`);
if (count) assertRange(buffer, offset, byteLength, `${name} animation records`);
return Array.from({ length: count }, (_, index) => {
const animationOffset = offset + index * recordSize;
return {
index,
id: buffer.readUInt16LE(animationOffset),
variationIndex: buffer.readUInt16LE(animationOffset + 2),
durationMs: buffer.readUInt32LE(animationOffset + 4),
moveSpeed: buffer.readFloatLE(animationOffset + 8),
flags: buffer.readUInt32LE(animationOffset + 12),
};
});
}
function decodeColors(buffer, name) {
const count = buffer.readUInt32LE(72);
const offset = buffer.readUInt32LE(76);
const recordSize = 40;
const byteLength = count * recordSize;
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} colors are too large to decode.`);
if (count) assertRange(buffer, offset, byteLength, `${name} color records`);
return Array.from({ length: count }, (_, index) => {
const colorOffset = offset + index * recordSize;
return {
color: decodeTrack(buffer, colorOffset, VALUE_TYPES.vector3, `${name} color ${index} RGB`),
alpha: decodeTrack(buffer, colorOffset + 20, VALUE_TYPES.int16, `${name} color ${index} alpha`),
};
});
}
function decodeTextureWeights(buffer, name) {
const count = buffer.readUInt32LE(88);
const offset = buffer.readUInt32LE(92);
const recordSize = 20;
const byteLength = count * recordSize;
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} texture weights are too large to decode.`);
if (count) assertRange(buffer, offset, byteLength, `${name} texture-weight records`);
return Array.from({ length: count }, (_, index) => (
decodeTrack(buffer, offset + index * recordSize, VALUE_TYPES.int16, `${name} texture weight ${index}`)
));
}
/** Source-space bone and UV animation, shared by meshes, particles and ribbons. */
export function decodeWotlkM2Animation(buffer, name = "M2") {
const bones = readArrayHeader(buffer, 44, `${name} bones`);
if (bones.count) assertRange(buffer, bones.offset, bones.count * 88, `${name} bones`);
const transforms = readArrayHeader(buffer, 96, `${name} texture transforms`);
if (transforms.count) assertRange(buffer, transforms.offset, transforms.count * 60, `${name} texture transforms`);
return {
globalSequences: decodeArray(buffer, 20, VALUE_TYPES.uint32, `${name} global sequences`).values,
bones: Array.from({ length: bones.count }, (_, index) => {
const offset = bones.offset + index * 88;
const parent = buffer.readInt16LE(offset + 8);
if (parent >= index || parent < -1) throw new Error(`${name} bone ${index} has an invalid parent.`);
return {
parent,
flags: buffer.readUInt32LE(offset + 4),
pivot: vector3(buffer, offset + 76),
translation: decodeTrack(buffer, offset + 16, VALUE_TYPES.vector3, `${name} bone ${index} translation`),
rotation: decodeTrack(buffer, offset + 36, VALUE_TYPES.quaternion, `${name} bone ${index} rotation`),
scale: decodeTrack(buffer, offset + 56, VALUE_TYPES.vector3, `${name} bone ${index} scale`),
};
}),
textureTransforms: Array.from({ length: transforms.count }, (_, index) => {
const offset = transforms.offset + index * 60;
return {
translation: decodeTrack(buffer, offset, VALUE_TYPES.vector3, `${name} UV ${index} translation`),
rotation: decodeTrack(buffer, offset + 20, VALUE_TYPES.floatQuaternion, `${name} UV ${index} rotation`),
scale: decodeTrack(buffer, offset + 40, VALUE_TYPES.vector3, `${name} UV ${index} scale`),
};
}),
materials: decodeMaterials(buffer, name),
};
}
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,
1,
`${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 globalSequences = decodeArray(m2, 20, VALUE_TYPES.uint32, `${name} global sequences`).values;
const animations = decodeAnimations(m2, name);
const colors = decodeColors(m2, name);
const textures = decodeTextures(m2, name);
const textureWeights = decodeTextureWeights(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`),
globalSequences,
animations,
bounds: {
minimum: vector3(m2, 160),
maximum: vector3(m2, 172),
radius: m2.readFloatLE(184),
},
vertices,
indices,
colors,
textures,
textureWeights,
materials,
lookups,
skin: {
magic: "SKIN",
maximumBones: skin.readUInt32LE(44),
vertexLookup,
triangleLookup,
boneIndices,
submeshes,
textureUnits,
},
};
}