323 lines
15 KiB
JavaScript
323 lines
15 KiB
JavaScript
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,
|
|
decodeWotlkSpellVisualKitRow,
|
|
parseDbc,
|
|
parseRunewakerImageRecords,
|
|
} 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("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)));
|
|
}
|
|
});
|