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)), }, 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}`) )); } 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 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, }, }; }