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 { 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"); const buffer = Buffer.alloc(20 + 8 + strings.length); buffer.write("WDBC", 0, "ascii"); buffer.writeUInt32LE(1, 4); buffer.writeUInt32LE(2, 8); buffer.writeUInt32LE(8, 12); buffer.writeUInt32LE(strings.length, 16); buffer.writeUInt32LE(42, 20); buffer.writeUInt32LE(1, 24); strings.copy(buffer, 28); 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); assert.equal(table.row(0).string(1), "hello"); }); test("classifies paths from fixed RuneWaker ImageObject records", () => { const buffer = Buffer.alloc(140 + 3712); buffer.writeInt32LE(1, 132); buffer.writeInt32LE(573727, 140); Buffer.from("model\\fx\\skill\\fire\\act_fireball01_f_i.ros\0", "ascii").copy(buffer, 140 + 300); const records = parseRunewakerImageRecords(buffer, [573727]); assert.deepEqual(records.get(573727), ["model/fx/skill/fire/act_fireball01_f_i.ros"]); }); 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 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(projectRoot, "public", sound.url))); } });