Files
healer-man/scripts/combat-effects/generate-manifest.test.mjs
T
2026-09-25 18:46:09 -04:00

439 lines
21 KiB
JavaScript

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("source-linked primary-five phases attach only browser-ready RuneWaker graphs", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "public/assets/game/combat-effects/manifest.json"), "utf8"));
const required = 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",
"490149:impact:model/fx/skill/holy/act_sanink_c_hit_point_e.ros",
"490450:impact:model/fx/skill/physical/act_snipe_t_freeze_hit.ros",
"490337:impact:model/fx/skill/physical/act_sneak_attack_t_freeze_hit.ros",
"491169:impact:model/fx/skill/wind/act_electric_explode_t_freeze_hit_e.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);
}
}
for (const key of required) assert.ok(actual.has(key), key);
assert.ok(actual.size > required.size);
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));
}
});