Release Healer Man 0.1.12
This commit is contained in:
1 parent
13970d03c8
commit
65e24f644e
201 files changed
+5061
-123
No files matched your search
@@ -1,9 +1,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { decodeWotlkM2Effects, parseDbc, parseRunewakerImageRecords } from "./generate-manifest.mjs";
|
||||
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");
|
||||
@@ -19,6 +21,132 @@ function tinyDbc() {
|
||||
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);
|
||||
@@ -34,39 +162,109 @@ test("classifies paths from fixed RuneWaker ImageObject records", () => {
|
||||
assert.deepEqual(records.get(573727), ["model/fx/skill/fire/act_fireball01_f_i.ros"]);
|
||||
});
|
||||
|
||||
test("decodes supported WotLK M2 particle and ribbon records", () => {
|
||||
const particleOffset = 304;
|
||||
const buffer = Buffer.alloc(particleOffset + 476);
|
||||
buffer.write("MD20", 0, "ascii");
|
||||
buffer.writeUInt32LE(1, 296);
|
||||
buffer.writeUInt32LE(particleOffset, 300);
|
||||
buffer.writeUInt32LE(17, particleOffset);
|
||||
buffer.writeUInt16LE(4, particleOffset + 20);
|
||||
buffer.writeUInt16LE(9, particleOffset + 22);
|
||||
buffer.writeUInt8(2, particleOffset + 40);
|
||||
buffer.writeUInt8(1, particleOffset + 41);
|
||||
buffer.writeUInt16LE(4, particleOffset + 48);
|
||||
buffer.writeUInt16LE(8, particleOffset + 50);
|
||||
buffer.writeUInt16LE(1, particleOffset + 56);
|
||||
buffer.writeUInt32LE(3, particleOffset + 56 + 12);
|
||||
const decoded = decodeWotlkM2Effects(buffer, "fixture");
|
||||
assert.equal(decoded.particles[0].id, 17);
|
||||
assert.equal(decoded.particles[0].textureIndex, 9);
|
||||
assert.equal(decoded.particles[0].blendMode, 2);
|
||||
assert.equal(decoded.particles[0].textureColumns, 8);
|
||||
assert.equal(decoded.particles[0].tracks.emissionSpeed.interpolation, 1);
|
||||
assert.equal(decoded.particles[0].tracks.emissionSpeed.keyCount, 3);
|
||||
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 root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "public", "assets", "game", "combat-effects", "manifest.json"), "utf8"));
|
||||
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(root, "public", sound.url)));
|
||||
assert.ok(fs.existsSync(path.join(projectRoot, "public", sound.url)));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user