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(800); 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; const animationOffset = 544; const colorOffset = 608; const textureWeightOffset = 648; const colorTimestampSequencesOffset = 672; const colorValueSequencesOffset = 680; const alphaTimestampSequencesOffset = 688; const alphaValueSequencesOffset = 696; const weightTimestampSequencesOffset = 704; const weightValueSequencesOffset = 712; const colorTimestampsOffset = 728; const colorValuesOffset = 736; const alphaTimestampsOffset = 760; const alphaValuesOffset = 768; const weightTimestampsOffset = 772; const weightValuesOffset = 780; 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(1, 28); m2.writeUInt32LE(animationOffset, 32); m2.writeUInt32LE(3, 60); m2.writeUInt32LE(vertexOffset, 64); m2.writeUInt32LE(1, 72); m2.writeUInt32LE(colorOffset, 76); m2.writeUInt32LE(1, 80); m2.writeUInt32LE(textureOffset, 84); m2.writeUInt32LE(1, 88); m2.writeUInt32LE(textureWeightOffset, 92); 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); m2.writeUInt16LE(0, animationOffset); m2.writeUInt16LE(0, animationOffset + 2); m2.writeUInt32LE(500, animationOffset + 4); m2.writeFloatLE(0, animationOffset + 8); m2.writeUInt32LE(32, animationOffset + 12); const writeTrackHeader = (offset, interpolation, timestampSequencesOffset, valueSequencesOffset) => { m2.writeUInt16LE(interpolation, offset); m2.writeInt16LE(-1, offset + 2); m2.writeUInt32LE(1, offset + 4); m2.writeUInt32LE(timestampSequencesOffset, offset + 8); m2.writeUInt32LE(1, offset + 12); m2.writeUInt32LE(valueSequencesOffset, offset + 16); }; const writeSequence = (descriptorOffset, count, valuesOffset) => { m2.writeUInt32LE(count, descriptorOffset); m2.writeUInt32LE(valuesOffset, descriptorOffset + 4); }; writeTrackHeader(colorOffset, 1, colorTimestampSequencesOffset, colorValueSequencesOffset); writeTrackHeader(colorOffset + 20, 0, alphaTimestampSequencesOffset, alphaValueSequencesOffset); writeTrackHeader(textureWeightOffset, 1, weightTimestampSequencesOffset, weightValueSequencesOffset); writeSequence(colorTimestampSequencesOffset, 2, colorTimestampsOffset); writeSequence(colorValueSequencesOffset, 2, colorValuesOffset); writeSequence(alphaTimestampSequencesOffset, 2, alphaTimestampsOffset); writeSequence(alphaValueSequencesOffset, 2, alphaValuesOffset); writeSequence(weightTimestampSequencesOffset, 2, weightTimestampsOffset); writeSequence(weightValueSequencesOffset, 2, weightValuesOffset); [0, 500].forEach((value, index) => { m2.writeUInt32LE(value, colorTimestampsOffset + index * 4); m2.writeUInt32LE(value, alphaTimestampsOffset + index * 4); m2.writeUInt32LE(value, weightTimestampsOffset + index * 4); }); [1, 0.8, 0.2, 0.5, 0.4, 0.1].forEach((value, index) => ( m2.writeFloatLE(value, colorValuesOffset + index * 4) )); m2.writeInt16LE(32_767, alphaValuesOffset); m2.writeInt16LE(16_384, alphaValuesOffset + 2); m2.writeInt16LE(0, weightValuesOffset); m2.writeInt16LE(6_553, weightValuesOffset + 2); 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(0, 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.animations, [{ index: 0, id: 0, variationIndex: 0, durationMs: 500, moveSpeed: 0, flags: 32, }]); assert.deepEqual(decoded.colors, [{ color: { interpolation: 1, globalSequence: -1, sequences: [{ timestamps: [0, 500], values: [ [1, Math.fround(0.8), Math.fround(0.2)], [0.5, Math.fround(0.4), Math.fround(0.1)], ], }], }, alpha: { interpolation: 0, globalSequence: -1, sequences: [{ timestamps: [0, 500], values: [32_767, 16_384] }], }, }]); assert.deepEqual(decoded.textures, [{ type: 0, flags: 0, filename: "SPELLS\\SYNTHETIC.BLP" }]); assert.deepEqual(decoded.textureWeights, [{ interpolation: 1, globalSequence: -1, sequences: [{ timestamps: [0, 500], values: [0, 6_553] }], }]); 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: 0, 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.animations.map(({ id, durationMs }) => ({ id, durationMs })), [ { id: 0, durationMs: 700 }, { id: 158, durationMs: 633 }, { id: 159, durationMs: 1100 }, ]); assert.equal(decoded.colors.length, 3); assert.equal(decoded.textureWeights.length, 3); assert.deepEqual(decoded.colors[0].color.sequences[0].values[0], [ 0.9686275124549866, 0.9921569228172302, 0.3176470696926117, ]); assert.deepEqual(decoded.colors.map((color) => color.alpha.sequences[0].values[0]), [ 32_767, 32_767, 32_767, ]); assert.deepEqual(decoded.textureWeights[1].sequences[1], { timestamps: [0, 300, 633], values: [4_915, 1_966, 4_915], }); 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)); });