import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { decodeWotlkM2Effects, decodeWotlkSpellVisualKitRow, parseDbc, parseRunewakerImageRecords, runewakerSoundPathsForModel, } from "./generate-manifest.mjs"; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); test("keeps WotLK SpellVisualKit SoundID out of the model effect list", () => { const fields = Array.from({ length: 17 }, () => 0); fields[3] = 101; fields[14] = 112; fields[15] = 3_085; fields[16] = 999; assert.deepEqual(decodeWotlkSpellVisualKitRow({ uint: (field) => fields[field] ?? 0 }), { effects: [101, 112], soundId: 3_085, }); }); 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("matches RuneWaker sound siblings without guessing unrelated cues", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rom-sound-fixture-")); try { const soundRoot = path.join(root, "sound", "fx", "skill", "fire"); fs.mkdirSync(soundRoot, { recursive: true }); fs.writeFileSync(path.join(soundRoot, "act_fireball_t_hit.ros.mp3"), "fixture"); assert.deepEqual(runewakerSoundPathsForModel( "model/fx/skill/fire/act_fireball_t_hit.ros", root, ), ["sound/fx/skill/fire/act_fireball_t_hit.ros.mp3"]); assert.deepEqual(runewakerSoundPathsForModel( "model/fx/skill/fire/act_other_t_hit.ros", root, ), []); assert.deepEqual(runewakerSoundPathsForModel( "model/../../sound/fx/skill/fire/act_fireball_t_hit.ros", root, ), []); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); 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 Priest mappings keep SpellVisualKit sounds out of visual phases", () => { const manifest = JSON.parse(fs.readFileSync( path.join(projectRoot, "public", "assets", "game", "combat-effects", "manifest.json"), "utf8", )); const entry = (spellId) => manifest.entries.find((candidate) => ( candidate.source === "wow335" && candidate.spellId === spellId )); const models = (spellId, phase) => (entry(spellId)?.phases?.[phase] ?? []).map((asset) => asset.model); const smiteRanks = [585, 591, 598, 984, 1004, 6060, 10933, 10934, 25363, 25364, 48122, 48123]; const shadowWordPainRanks = [589, 594, 970, 992, 2767, 10892, 10893, 10894, 25367, 25368, 48124, 48125]; for (const spellId of smiteRanks) { assert.deepEqual(models(spellId, "cast-start"), ["Spells/Holy_Precast_Low_Hand.mdx"]); assert.deepEqual(models(spellId, "impact"), ["Spells/HolySmite_Low_Chest.mdx"]); } for (const spellId of shadowWordPainRanks) { assert.deepEqual(models(spellId, "cast-start"), ["Spells/Shadow_Precast_Low_Hand.mdx"]); assert.deepEqual(models(spellId, "impact"), ["Spells/ShadowWordPain_Chest.mdx"]); } assert.deepEqual(models(17, "aura-start"), ["Spells/HolyDivineShield_State_Base.mdx"]); const smite = entry(585); assert.equal(smite.phases.impact[0].effectKind, "wow-m2-model"); assert.match(smite.phases.impact[0].effectGraphUrl, /\/original\/wow335\/all\/graphs\/holysmite-low-chest-[a-f0-9]+\.json$/); assert.ok((smite.sounds ?? []).some((sound) => sound.sourcePath === "Sound/Spells/HolyBolt.wav")); const pain = entry(589); assert.equal(pain.phases.impact[0].effectKind, "wow-m2-model"); assert.match(pain.phases.impact[0].effectGraphUrl, /\/original\/wow335\/all\/graphs\/shadowwordpain-chest-[a-f0-9]+\.json$/); assert.ok((pain.sounds ?? []).some((sound) => sound.sourcePath === "Sound/Spells/ShadowWordPainTarget.wav")); }); 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))); } }); test("primary-five RuneWaker spells retain source phases and packaged sound cues", () => { const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "public/assets/game/combat-effects/manifest.json"), "utf8")); const entry = (id) => manifest.entries.find((candidate) => candidate.key === `runewaker:${id}`); assert.ok(entry(490261).phases["cast-start"].some((asset) => asset.model === "model/fx/skill/holy/act_casting_c_hand.ros")); assert.ok(entry(490261).sounds.some((sound) => sound.phase === "cast-start" && sound.url)); assert.ok(entry(491128).phases.release.some((asset) => asset.model.includes("act_gustwindarrow_f_hit_point_i.ros"))); assert.ok(entry(491128).phases.impact.some((asset) => asset.model.includes("act_gustwindarrow_t_hit_point_e.ros"))); assert.ok(entry(490495).sounds.some((sound) => sound.sourcePath === "sound/fx/skill/holy/act_godshield_c_down.mp3")); }); test("Priest target and projectile sounds follow their verified ROS event phases", () => { const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "public/assets/game/combat-effects/manifest.json"), "utf8")); const archive = JSON.parse(fs.readFileSync(path.join(projectRoot, "public/assets/game/combat-effects/original/runewaker/manifest.json"), "utf8")); for (const { spellId, phase, motion, model, sourcePath } of [ { spellId: 491147, phase: "impact", motion: "explode", model: "model/fx/skill/holy/act_benedictory_heal_t_down.ros", sourcePath: "sound/fx/skill/holy/act_benedictory_heal_t_down.mp3", }, { spellId: 490256, phase: "release", motion: "appear", model: "model/fx/skill/ice/act_icecone01_f.ros", sourcePath: "sound/fx/skill/ice/icenew/act_icecone01_f.ros.mp3", }, { spellId: 490256, phase: "impact", motion: "explode", model: "model/fx/skill/ice/act_riptide_t_freeze_hit.ros", sourcePath: "sound/fx/skill/ice/act_riptide_t_freeze_hit.mp3", }, ]) { const entry = manifest.entries.find((candidate) => candidate.key === `runewaker:${spellId}`); assert.ok(entry?.phases?.[phase]?.some((asset) => asset.model === model)); const cues = entry.sounds.filter((sound) => sound.phase === phase && sound.sourcePath === sourcePath); assert.equal(cues.length, 1); assert.ok(cues[0].url?.startsWith("/assets/game/combat-effects/audio/")); assert.ok(fs.existsSync(path.join(projectRoot, "public", cues[0].url.slice(1)))); const result = archive.results.find((candidate) => candidate.model === model); assert.ok(result?.graphUrl); const graph = JSON.parse(fs.readFileSync(path.join(projectRoot, "public", result.graphUrl.slice(1)), "utf8")); assert.ok(graph.motions.some((candidate) => candidate.name === motion && candidate.controllers.some((controller) => controller.sourceType === "CRuController_EventTrigger" && controller.eventID === 43 && controller.eventParameter0 === 0 && controller.eventString?.replaceAll("\\", "/").toLowerCase() === sourcePath))); } }); test("only browser-verified RuneWaker particle phases use original graphs", () => { const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "public/assets/game/combat-effects/manifest.json"), "utf8")); const expected = new Set([ "491147:cast-start:model/fx/skill/holy/act_casting_c_hand.ros", "491147:impact:model/fx/skill/holy/act_benedictory_heal_t_down.ros", "490261:cast-start:model/fx/skill/holy/act_casting_c_hand.ros", "490281:cast-start:model/fx/skill/holy/act_casting_c_hand.ros", "490256:cast-start:model/fx/skill/ice/act_icecone01_c_hand.ros", "490256:release:model/fx/skill/ice/act_icecone01_f.ros", "490256:impact:model/fx/skill/ice/act_riptide_t_freeze_hit.ros", "490150:cast-start:model/fx/skill/physical/act_charge02_c_down.ros", "490175:impact:model/fx/hit_fx/act_shieldstorm_t_freeze_hit_e.ros", "491168:release:model/fx/skill/wind/act_electric_ball_f_i.ros", ]); const actual = new Set(); for (const entry of manifest.entries.filter((candidate) => candidate.source === "runewaker")) { for (const [phase, assets] of Object.entries(entry.phases ?? {})) for (const asset of assets) { if (!asset.effectGraphUrl) continue; const key = `${entry.spellId}:${phase}:${asset.model}`; actual.add(key); assert.equal(asset.effectKind, "runewaker-particles"); if (entry.spellId === 490175) assert.equal(asset.scale, 2.5); const graph = JSON.parse(fs.readFileSync(path.join(projectRoot, "public", asset.effectGraphUrl.slice(1)), "utf8")); if (key === "491147:impact:model/fx/skill/holy/act_benedictory_heal_t_down.ros") { assert.equal(asset.visualCompleteness, "partial"); assert.equal(graph.partialRendererReady, true); assert.equal(graph.rendererReady, false); } else { assert.equal(asset.visualCompleteness, undefined); assert.equal(graph.rendererReady, true); } assert.equal(graph.sourceModel, asset.model); } } assert.deepEqual(actual, expected); for (const spellId of [491147, 490261, 490281]) { const entry = manifest.entries.find((candidate) => candidate.key === `runewaker:${spellId}`); assert.ok(entry.sounds.some((sound) => sound.phase === "cast-start" && sound.sourcePath === "sound/fx/skill/holy/act_casting_c_hand.wav" && sound.loopDuringCast === true)); } });