forked from phenom/healer-man
298 lines
12 KiB
JavaScript
298 lines
12 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 = {
|
|
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,
|
|
},
|
|
};
|
|
}
|