Release Healer Man 0.1.13
This commit is contained in:
1 parent
65e24f644e
commit
67d7a8ebe7
324 files changed
+917
-48
No files matched your search
@@ -42,6 +42,17 @@ export function parseDbc(buffer, name = "DBC") {
|
||||
return { name, buffer, recordCount, fieldCount, recordSize, stringSize, row };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the WotLK SpellVisualKit field boundary. Fields 3..14 are visual
|
||||
* effect-name IDs; field 15 is SoundID and must never be resolved as a model.
|
||||
*/
|
||||
export function decodeWotlkSpellVisualKitRow(row) {
|
||||
return {
|
||||
effects: Array.from({ length: 12 }, (_, offset) => row.uint(3 + offset)).filter(Boolean),
|
||||
soundId: row.uint(15),
|
||||
};
|
||||
}
|
||||
|
||||
const M2_VALUE_TYPES = {
|
||||
float32: { byteLength: 4, read: (buffer, offset) => buffer.readFloatLE(offset) },
|
||||
uint32: { byteLength: 4, read: (buffer, offset) => buffer.readUInt32LE(offset) },
|
||||
@@ -379,10 +390,7 @@ function buildWowEntries(source, requestedIds, tables) {
|
||||
casterImpact: row.uint(14), targetImpact: row.uint(15), instantArea: row.uint(23), impactArea: row.uint(24), persistentArea: row.uint(25),
|
||||
}));
|
||||
const kitsTable = parseDbc(tables.SpellVisualKit, `${source} SpellVisualKit.dbc`);
|
||||
const kits = tableById(kitsTable, (row) => ({
|
||||
effects: Array.from({ length: 13 }, (_, offset) => row.uint(3 + offset)).filter(Boolean),
|
||||
soundId: row.uint(16),
|
||||
}));
|
||||
const kits = tableById(kitsTable, decodeWotlkSpellVisualKitRow);
|
||||
const effectNames = tableById(parseDbc(tables.SpellVisualEffectName, `${source} SpellVisualEffectName.dbc`), (row) => ({
|
||||
name: row.string(1),
|
||||
model: row.string(2).replaceAll("\\", "/"),
|
||||
@@ -579,6 +587,9 @@ function extractedAscensionTables(spellPath, supportRoot, baseSoundEntries) {
|
||||
|
||||
function openedWowAudioArchives(dataRoot) {
|
||||
const names = [
|
||||
"enUS/patch-enUS-3.MPQ", "enUS/patch-enUS-2.MPQ", "enUS/patch-enUS-N.MPQ",
|
||||
"enUS/patch-enUS-M.MPQ", "enUS/patch-enUS.MPQ", "enUS/lichking-speech-enUS.MPQ",
|
||||
"enUS/expansion-speech-enUS.MPQ", "enUS/speech-enUS.MPQ", "enUS/locale-enUS.MPQ",
|
||||
"patch-3.MPQ", "patch-2.MPQ", "patch.MPQ", "Patch-B.MPQ", "patch-A.MPQ",
|
||||
"lichking.MPQ", "expansion.MPQ", "common-2.MPQ", "common.MPQ",
|
||||
];
|
||||
@@ -611,13 +622,22 @@ function packageAudioAssets(entries, options) {
|
||||
if (fs.existsSync(candidate)) buffer = fs.readFileSync(candidate);
|
||||
} else {
|
||||
const archiveName = sourcePath.replaceAll("/", "\\");
|
||||
for (const { archive } of archives) {
|
||||
try {
|
||||
buffer = archive.extractFile(archiveName);
|
||||
if (buffer?.length) break;
|
||||
} catch {
|
||||
// The source archive stack is sparse by design; continue to the base archives.
|
||||
// Some WotLK SoundEntries use a historical Creature/*VO directory while
|
||||
// the matching non-voice line remained in Creature/* in common.MPQ.
|
||||
const archiveNames = [...new Set([
|
||||
archiveName,
|
||||
archiveName.replace(/^(Sound\\Creature\\[^\\]+)VO\\/i, "$1\\"),
|
||||
])];
|
||||
for (const candidateName of archiveNames) {
|
||||
for (const { archive } of archives) {
|
||||
try {
|
||||
buffer = archive.extractFile(candidateName);
|
||||
if (buffer?.length) break;
|
||||
} catch {
|
||||
// The source archive stack is sparse by design; continue to the base archives.
|
||||
}
|
||||
}
|
||||
if (buffer?.length) break;
|
||||
}
|
||||
}
|
||||
sourceCache.set(cacheKey, buffer);
|
||||
|
||||
@@ -3,10 +3,28 @@ 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";
|
||||
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);
|
||||
@@ -257,6 +275,38 @@ test("decodes real local WotLK Holy Smite particles and ribbons", {
|
||||
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].effectGraphUrl,
|
||||
"/assets/game/combat-effects/original/wow335/priest/graphs/holysmite-low-chest-5e638a90a1be.json");
|
||||
assert.ok((smite.sounds ?? []).some((sound) => sound.sourcePath === "Sound/Spells/HolyBolt.wav"));
|
||||
const pain = entry(589);
|
||||
assert.equal(pain.phases.impact[0].effectGraphUrl,
|
||||
"/assets/game/combat-effects/original/wow335/priest/graphs/shadowwordpain-chest-8c57a0056fda.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);
|
||||
|
||||
@@ -335,6 +335,8 @@ function packageStaticShieldModel(sourceModel, options, missingTextures) {
|
||||
sourceSha256: modelHash,
|
||||
sourceSkin: normalizedSkin,
|
||||
sourceSkinSha256: skinHash,
|
||||
globalSequences: decoded.globalSequences,
|
||||
animations: decoded.animations,
|
||||
bounds: decoded.bounds,
|
||||
geometry: {
|
||||
positions: decoded.vertices.flatMap((vertex) => vertex.position),
|
||||
@@ -342,7 +344,9 @@ function packageStaticShieldModel(sourceModel, options, missingTextures) {
|
||||
uvs: decoded.vertices.flatMap((vertex) => vertex.uv),
|
||||
indices: decoded.indices,
|
||||
},
|
||||
colors: decoded.colors,
|
||||
textures,
|
||||
textureWeights: decoded.textureWeights,
|
||||
layers,
|
||||
runtime: {
|
||||
renderer: "wow-m2-static-mesh-v1",
|
||||
@@ -448,10 +452,13 @@ export function packagePriestOriginalEffects(manifest, options = {}) {
|
||||
}
|
||||
const packaged = modelResults.get(key);
|
||||
const { effectGraphUrl: _oldUrl, effectKind: _oldKind, originalSourceSha256: _oldHash, ...sourceAsset } = asset;
|
||||
if (packaged.status !== "packaged") return sourceAsset;
|
||||
const presentationAsset = isPowerWordShieldMesh
|
||||
? { ...sourceAsset, scale: 0.7 }
|
||||
: sourceAsset;
|
||||
if (packaged.status !== "packaged") return presentationAsset;
|
||||
attachedAssets += 1;
|
||||
return {
|
||||
...sourceAsset,
|
||||
...presentationAsset,
|
||||
effectKind: packaged.effectKind ?? "wow-m2-particles",
|
||||
effectGraphUrl: packaged.graphUrl,
|
||||
originalSourceSha256: packaged.sourceSha256,
|
||||
|
||||
@@ -138,6 +138,16 @@ test("packages the Power Word: Shield aura as a three-layer static mesh graph",
|
||||
for (const texture of ["ShieldA.BLP", "ShieldB.BLP", "ShieldC.BLP"]) {
|
||||
fs.writeFileSync(path.join(textureRoot, "Spells", texture), Buffer.from(`source-${texture}`));
|
||||
}
|
||||
const scalarTrack = (values, interpolation = 1) => ({
|
||||
interpolation,
|
||||
globalSequence: -1,
|
||||
sequences: [{ timestamps: values.map((_, index) => index * 500), values }],
|
||||
});
|
||||
const colorTrack = (color) => ({
|
||||
interpolation: 0,
|
||||
globalSequence: -1,
|
||||
sequences: [{ timestamps: [0], values: [color] }],
|
||||
});
|
||||
const sourceManifest = {
|
||||
schemaVersion: 1,
|
||||
coverage: { requested: 1, authentic: 1, fallback: 0 },
|
||||
@@ -151,6 +161,12 @@ test("packages the Power Word: Shield aura as a three-layer static mesh graph",
|
||||
}],
|
||||
};
|
||||
const decodedMesh = {
|
||||
globalSequences: [],
|
||||
animations: [
|
||||
{ index: 0, id: 0, variationIndex: 0, durationMs: 700, moveSpeed: 0, flags: 33 },
|
||||
{ index: 1, id: 158, variationIndex: 0, durationMs: 633, moveSpeed: 0, flags: 32 },
|
||||
{ index: 2, id: 159, variationIndex: 0, durationMs: 1_100, moveSpeed: 0, flags: 33 },
|
||||
],
|
||||
bounds: { minimum: [-1, -1, 0], maximum: [1, 1, 0], radius: 1.5 },
|
||||
vertices: [
|
||||
{ position: [0, 0, 0], normal: [1, 0, 0], uv: [0, 0] },
|
||||
@@ -158,9 +174,14 @@ test("packages the Power Word: Shield aura as a three-layer static mesh graph",
|
||||
{ position: [0, 0, 1], normal: [1, 0, 0], uv: [0, 1] },
|
||||
],
|
||||
indices: [0, 1, 2, 0, 1, 2, 0, 1, 2],
|
||||
colors: [0, 1, 2].map((index) => ({
|
||||
color: colorTrack([1, 0.9 - index * 0.1, 0.3]),
|
||||
alpha: scalarTrack([32_767], 0),
|
||||
})),
|
||||
textures: ["ShieldA.BLP", "ShieldB.BLP", "ShieldC.BLP"].map((filename) => ({
|
||||
type: 0, flags: 0, filename: `Spells/${filename}`,
|
||||
})),
|
||||
textureWeights: [0, 1, 2].map(() => scalarTrack([0, 6_553])),
|
||||
materials: [0, 1, 2].map(() => ({ flags: 19, blendMode: 4 })),
|
||||
skin: {
|
||||
textureUnits: [0, 1, 2].map((index) => ({
|
||||
@@ -191,6 +212,7 @@ test("packages the Power Word: Shield aura as a three-layer static mesh graph",
|
||||
|
||||
const asset = manifest.entries[0].phases["aura-start"][0];
|
||||
assert.equal(asset.effectKind, "wow-m2-static-mesh");
|
||||
assert.equal(asset.scale, 0.7);
|
||||
assert.match(asset.effectGraphUrl, /^\/fixture\/priest\/graphs\/holydivineshield-state-base-/);
|
||||
assert.equal(report.readyStaticMeshes, 1);
|
||||
assert.equal(report.readyParticleEmitters, 0);
|
||||
@@ -204,6 +226,18 @@ test("packages the Power Word: Shield aura as a three-layer static mesh graph",
|
||||
indices: [0, 1, 2, 0, 1, 2, 0, 1, 2],
|
||||
});
|
||||
assert.equal(graph.layers.length, 3);
|
||||
assert.deepEqual(graph.animations.map(({ id, durationMs }) => ({ id, durationMs })), [
|
||||
{ id: 0, durationMs: 700 },
|
||||
{ id: 158, durationMs: 633 },
|
||||
{ id: 159, durationMs: 1_100 },
|
||||
]);
|
||||
assert.equal(graph.colors.length, 3);
|
||||
assert.equal(graph.textureWeights.length, 3);
|
||||
assert.deepEqual(graph.textureWeights[0], {
|
||||
interpolation: 1,
|
||||
globalSequence: -1,
|
||||
sequences: [{ timestamps: [0, 500], values: [0, 6_553] }],
|
||||
});
|
||||
assert.deepEqual(graph.layers.map((layer) => ({
|
||||
textureIndex: layer.textureIndex,
|
||||
blendMode: layer.blendMode,
|
||||
|
||||
@@ -11,8 +11,10 @@ const VALUE_TYPES = {
|
||||
byteLength: 4,
|
||||
read: (buffer, offset) => Array.from(buffer.subarray(offset, offset + 4)),
|
||||
},
|
||||
uint32: { byteLength: 4, read: (buffer, offset) => buffer.readUInt32LE(offset) },
|
||||
uint16: { byteLength: 2, read: (buffer, offset) => buffer.readUInt16LE(offset) },
|
||||
int16: { byteLength: 2, read: (buffer, offset) => buffer.readInt16LE(offset) },
|
||||
vector3: { byteLength: 12, read: (buffer, offset) => vector3(buffer, offset) },
|
||||
};
|
||||
|
||||
function asBuffer(value) {
|
||||
@@ -44,6 +46,54 @@ function decodeArray(buffer, descriptorOffset, valueType, label) {
|
||||
};
|
||||
}
|
||||
|
||||
function decodeNestedArray(buffer, descriptorOffset, valueType, label) {
|
||||
const descriptor = readArrayHeader(buffer, descriptorOffset, label);
|
||||
const byteLength = descriptor.count * 8;
|
||||
if (!Number.isSafeInteger(byteLength)) throw new Error(`${label} is too large to decode.`);
|
||||
if (descriptor.count) assertRange(buffer, descriptor.offset, byteLength, `${label} sequence descriptors`);
|
||||
return {
|
||||
...descriptor,
|
||||
sequences: Array.from({ length: descriptor.count }, (_, index) => (
|
||||
decodeArray(buffer, descriptor.offset + index * 8, valueType, `${label} sequence ${index}`)
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeTrack(buffer, offset, valueType, label) {
|
||||
assertRange(buffer, offset, 20, `${label} track`);
|
||||
const interpolation = buffer.readUInt16LE(offset);
|
||||
if (interpolation > 3) throw new Error(`${label} uses unsupported interpolation ${interpolation}.`);
|
||||
const spline = interpolation === 2 || interpolation === 3;
|
||||
const trackValueType = spline ? {
|
||||
byteLength: valueType.byteLength * 3,
|
||||
read: (source, valueOffset) => ({
|
||||
value: valueType.read(source, valueOffset),
|
||||
inTangent: valueType.read(source, valueOffset + valueType.byteLength),
|
||||
outTangent: valueType.read(source, valueOffset + valueType.byteLength * 2),
|
||||
}),
|
||||
} : valueType;
|
||||
const timestamps = decodeNestedArray(buffer, offset + 4, VALUE_TYPES.uint32, `${label} timestamps`);
|
||||
const values = decodeNestedArray(buffer, offset + 12, trackValueType, `${label} values`);
|
||||
if (timestamps.sequences.length !== values.sequences.length) {
|
||||
throw new Error(`${label} timestamp and value sequence counts do not match.`);
|
||||
}
|
||||
const sequences = timestamps.sequences.map((timestampSequence, index) => {
|
||||
const valueSequence = values.sequences[index];
|
||||
if (timestampSequence.values.length !== valueSequence.values.length) {
|
||||
throw new Error(`${label} sequence ${index} timestamp and value counts do not match.`);
|
||||
}
|
||||
return {
|
||||
timestamps: timestampSequence.values,
|
||||
values: valueSequence.values,
|
||||
};
|
||||
});
|
||||
return {
|
||||
interpolation,
|
||||
globalSequence: buffer.readInt16LE(offset + 2),
|
||||
sequences,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeString(buffer, descriptorOffset, label) {
|
||||
const descriptor = readArrayHeader(buffer, descriptorOffset, label);
|
||||
if (!descriptor.count) return "";
|
||||
@@ -113,6 +163,54 @@ function decodeMaterials(buffer, name) {
|
||||
});
|
||||
}
|
||||
|
||||
function decodeAnimations(buffer, name) {
|
||||
const count = buffer.readUInt32LE(28);
|
||||
const offset = buffer.readUInt32LE(32);
|
||||
const recordSize = 64;
|
||||
const byteLength = count * recordSize;
|
||||
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} animations are too large to decode.`);
|
||||
if (count) assertRange(buffer, offset, byteLength, `${name} animation records`);
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const animationOffset = offset + index * recordSize;
|
||||
return {
|
||||
index,
|
||||
id: buffer.readUInt16LE(animationOffset),
|
||||
variationIndex: buffer.readUInt16LE(animationOffset + 2),
|
||||
durationMs: buffer.readUInt32LE(animationOffset + 4),
|
||||
moveSpeed: buffer.readFloatLE(animationOffset + 8),
|
||||
flags: buffer.readUInt32LE(animationOffset + 12),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function decodeColors(buffer, name) {
|
||||
const count = buffer.readUInt32LE(72);
|
||||
const offset = buffer.readUInt32LE(76);
|
||||
const recordSize = 40;
|
||||
const byteLength = count * recordSize;
|
||||
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} colors are too large to decode.`);
|
||||
if (count) assertRange(buffer, offset, byteLength, `${name} color records`);
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const colorOffset = offset + index * recordSize;
|
||||
return {
|
||||
color: decodeTrack(buffer, colorOffset, VALUE_TYPES.vector3, `${name} color ${index} RGB`),
|
||||
alpha: decodeTrack(buffer, colorOffset + 20, VALUE_TYPES.int16, `${name} color ${index} alpha`),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function decodeTextureWeights(buffer, name) {
|
||||
const count = buffer.readUInt32LE(88);
|
||||
const offset = buffer.readUInt32LE(92);
|
||||
const recordSize = 20;
|
||||
const byteLength = count * recordSize;
|
||||
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} texture weights are too large to decode.`);
|
||||
if (count) assertRange(buffer, offset, byteLength, `${name} texture-weight records`);
|
||||
return Array.from({ length: count }, (_, index) => (
|
||||
decodeTrack(buffer, offset + index * recordSize, VALUE_TYPES.int16, `${name} texture weight ${index}`)
|
||||
));
|
||||
}
|
||||
|
||||
function decodeSkinSubmeshes(buffer, count, offset, triangleCount, vertexLookupCount, name) {
|
||||
const byteLength = count * SKIN_SUBMESH_SIZE;
|
||||
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} submeshes are too large to decode.`);
|
||||
@@ -231,7 +329,11 @@ export function decodeWotlkM2Mesh(m2Source, skinSource, name = "M2 mesh") {
|
||||
}
|
||||
|
||||
const vertices = decodeVertices(m2, name);
|
||||
const globalSequences = decodeArray(m2, 20, VALUE_TYPES.uint32, `${name} global sequences`).values;
|
||||
const animations = decodeAnimations(m2, name);
|
||||
const colors = decodeColors(m2, name);
|
||||
const textures = decodeTextures(m2, name);
|
||||
const textureWeights = decodeTextureWeights(m2, name);
|
||||
const materials = decodeMaterials(m2, name);
|
||||
const lookups = {
|
||||
textures: decodeArray(m2, 128, VALUE_TYPES.uint16, `${name} texture lookup`).values,
|
||||
@@ -274,6 +376,8 @@ export function decodeWotlkM2Mesh(m2Source, skinSource, name = "M2 mesh") {
|
||||
magic: "MD20",
|
||||
version: 264,
|
||||
modelName: decodeString(m2, 8, `${name} model name`),
|
||||
globalSequences,
|
||||
animations,
|
||||
bounds: {
|
||||
minimum: vector3(m2, 160),
|
||||
maximum: vector3(m2, 172),
|
||||
@@ -281,7 +385,9 @@ export function decodeWotlkM2Mesh(m2Source, skinSource, name = "M2 mesh") {
|
||||
},
|
||||
vertices,
|
||||
indices,
|
||||
colors,
|
||||
textures,
|
||||
textureWeights,
|
||||
materials,
|
||||
lookups,
|
||||
skin: {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { decodeWotlkM2Mesh } from "./wotlk-m2-mesh.mjs";
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
function syntheticM2AndSkin() {
|
||||
const m2 = Buffer.alloc(640);
|
||||
const m2 = Buffer.alloc(800);
|
||||
const skin = Buffer.alloc(144);
|
||||
const vertexOffset = 304;
|
||||
const materialOffset = 448;
|
||||
@@ -19,6 +19,21 @@ function syntheticM2AndSkin() {
|
||||
const modelNameOffset = 464;
|
||||
const textureOffset = 496;
|
||||
const textureFilenameOffset = 512;
|
||||
const animationOffset = 544;
|
||||
const colorOffset = 608;
|
||||
const textureWeightOffset = 648;
|
||||
const colorTimestampSequencesOffset = 672;
|
||||
const colorValueSequencesOffset = 680;
|
||||
const alphaTimestampSequencesOffset = 688;
|
||||
const alphaValueSequencesOffset = 696;
|
||||
const weightTimestampSequencesOffset = 704;
|
||||
const weightValueSequencesOffset = 712;
|
||||
const colorTimestampsOffset = 728;
|
||||
const colorValuesOffset = 736;
|
||||
const alphaTimestampsOffset = 760;
|
||||
const alphaValuesOffset = 768;
|
||||
const weightTimestampsOffset = 772;
|
||||
const weightValuesOffset = 780;
|
||||
|
||||
m2.write("MD20", 0, "ascii");
|
||||
m2.writeUInt32LE(264, 4);
|
||||
@@ -26,10 +41,16 @@ function syntheticM2AndSkin() {
|
||||
m2.writeUInt32LE(modelName.length, 8);
|
||||
m2.writeUInt32LE(modelNameOffset, 12);
|
||||
modelName.copy(m2, modelNameOffset);
|
||||
m2.writeUInt32LE(1, 28);
|
||||
m2.writeUInt32LE(animationOffset, 32);
|
||||
m2.writeUInt32LE(3, 60);
|
||||
m2.writeUInt32LE(vertexOffset, 64);
|
||||
m2.writeUInt32LE(1, 72);
|
||||
m2.writeUInt32LE(colorOffset, 76);
|
||||
m2.writeUInt32LE(1, 80);
|
||||
m2.writeUInt32LE(textureOffset, 84);
|
||||
m2.writeUInt32LE(1, 88);
|
||||
m2.writeUInt32LE(textureWeightOffset, 92);
|
||||
m2.writeUInt32LE(1, 112);
|
||||
m2.writeUInt32LE(materialOffset, 116);
|
||||
m2.writeUInt32LE(1, 128);
|
||||
@@ -72,6 +93,46 @@ function syntheticM2AndSkin() {
|
||||
m2.writeUInt32LE(textureFilenameOffset, textureOffset + 12);
|
||||
textureFilename.copy(m2, textureFilenameOffset);
|
||||
|
||||
m2.writeUInt16LE(0, animationOffset);
|
||||
m2.writeUInt16LE(0, animationOffset + 2);
|
||||
m2.writeUInt32LE(500, animationOffset + 4);
|
||||
m2.writeFloatLE(0, animationOffset + 8);
|
||||
m2.writeUInt32LE(32, animationOffset + 12);
|
||||
|
||||
const writeTrackHeader = (offset, interpolation, timestampSequencesOffset, valueSequencesOffset) => {
|
||||
m2.writeUInt16LE(interpolation, offset);
|
||||
m2.writeInt16LE(-1, offset + 2);
|
||||
m2.writeUInt32LE(1, offset + 4);
|
||||
m2.writeUInt32LE(timestampSequencesOffset, offset + 8);
|
||||
m2.writeUInt32LE(1, offset + 12);
|
||||
m2.writeUInt32LE(valueSequencesOffset, offset + 16);
|
||||
};
|
||||
const writeSequence = (descriptorOffset, count, valuesOffset) => {
|
||||
m2.writeUInt32LE(count, descriptorOffset);
|
||||
m2.writeUInt32LE(valuesOffset, descriptorOffset + 4);
|
||||
};
|
||||
writeTrackHeader(colorOffset, 1, colorTimestampSequencesOffset, colorValueSequencesOffset);
|
||||
writeTrackHeader(colorOffset + 20, 0, alphaTimestampSequencesOffset, alphaValueSequencesOffset);
|
||||
writeTrackHeader(textureWeightOffset, 1, weightTimestampSequencesOffset, weightValueSequencesOffset);
|
||||
writeSequence(colorTimestampSequencesOffset, 2, colorTimestampsOffset);
|
||||
writeSequence(colorValueSequencesOffset, 2, colorValuesOffset);
|
||||
writeSequence(alphaTimestampSequencesOffset, 2, alphaTimestampsOffset);
|
||||
writeSequence(alphaValueSequencesOffset, 2, alphaValuesOffset);
|
||||
writeSequence(weightTimestampSequencesOffset, 2, weightTimestampsOffset);
|
||||
writeSequence(weightValueSequencesOffset, 2, weightValuesOffset);
|
||||
[0, 500].forEach((value, index) => {
|
||||
m2.writeUInt32LE(value, colorTimestampsOffset + index * 4);
|
||||
m2.writeUInt32LE(value, alphaTimestampsOffset + index * 4);
|
||||
m2.writeUInt32LE(value, weightTimestampsOffset + index * 4);
|
||||
});
|
||||
[1, 0.8, 0.2, 0.5, 0.4, 0.1].forEach((value, index) => (
|
||||
m2.writeFloatLE(value, colorValuesOffset + index * 4)
|
||||
));
|
||||
m2.writeInt16LE(32_767, alphaValuesOffset);
|
||||
m2.writeInt16LE(16_384, alphaValuesOffset + 2);
|
||||
m2.writeInt16LE(0, weightValuesOffset);
|
||||
m2.writeInt16LE(6_553, weightValuesOffset + 2);
|
||||
|
||||
skin.write("SKIN", 0, "ascii");
|
||||
skin.writeUInt32LE(3, 4);
|
||||
skin.writeUInt32LE(48, 8);
|
||||
@@ -109,7 +170,7 @@ function syntheticM2AndSkin() {
|
||||
skin.writeUInt16LE(0, unitOffset + 2);
|
||||
skin.writeUInt16LE(0, unitOffset + 4);
|
||||
skin.writeUInt16LE(0, unitOffset + 6);
|
||||
skin.writeInt16LE(-1, unitOffset + 8);
|
||||
skin.writeInt16LE(0, unitOffset + 8);
|
||||
skin.writeUInt16LE(0, unitOffset + 10);
|
||||
skin.writeUInt16LE(0, unitOffset + 12);
|
||||
skin.writeUInt16LE(1, unitOffset + 14);
|
||||
@@ -135,7 +196,38 @@ test("decodes browser-neutral M2 vertices and resolved SKIN draw data", () => {
|
||||
uv2: [1, 0],
|
||||
});
|
||||
assert.deepEqual(decoded.indices, [0, 1, 2]);
|
||||
assert.deepEqual(decoded.animations, [{
|
||||
index: 0,
|
||||
id: 0,
|
||||
variationIndex: 0,
|
||||
durationMs: 500,
|
||||
moveSpeed: 0,
|
||||
flags: 32,
|
||||
}]);
|
||||
assert.deepEqual(decoded.colors, [{
|
||||
color: {
|
||||
interpolation: 1,
|
||||
globalSequence: -1,
|
||||
sequences: [{
|
||||
timestamps: [0, 500],
|
||||
values: [
|
||||
[1, Math.fround(0.8), Math.fround(0.2)],
|
||||
[0.5, Math.fround(0.4), Math.fround(0.1)],
|
||||
],
|
||||
}],
|
||||
},
|
||||
alpha: {
|
||||
interpolation: 0,
|
||||
globalSequence: -1,
|
||||
sequences: [{ timestamps: [0, 500], values: [32_767, 16_384] }],
|
||||
},
|
||||
}]);
|
||||
assert.deepEqual(decoded.textures, [{ type: 0, flags: 0, filename: "SPELLS\\SYNTHETIC.BLP" }]);
|
||||
assert.deepEqual(decoded.textureWeights, [{
|
||||
interpolation: 1,
|
||||
globalSequence: -1,
|
||||
sequences: [{ timestamps: [0, 500], values: [0, 6_553] }],
|
||||
}]);
|
||||
assert.deepEqual(decoded.materials, [{ flags: 19, blendMode: 4 }]);
|
||||
assert.deepEqual(decoded.skin.submeshes[0].indices, [0, 1, 2]);
|
||||
assert.deepEqual(decoded.skin.textureUnits[0], {
|
||||
@@ -144,7 +236,7 @@ test("decodes browser-neutral M2 vertices and resolved SKIN draw data", () => {
|
||||
shaderId: 0,
|
||||
submeshIndex: 0,
|
||||
geosetIndex: 0,
|
||||
colorIndex: -1,
|
||||
colorIndex: 0,
|
||||
materialIndex: 0,
|
||||
materialLayer: 0,
|
||||
textureCount: 1,
|
||||
@@ -188,6 +280,25 @@ test("decodes the real local Power Word: Shield static mesh", {
|
||||
assert.equal(decoded.indices.length, 18);
|
||||
assert.equal(decoded.skin.submeshes.length, 3);
|
||||
assert.equal(decoded.skin.textureUnits.length, 3);
|
||||
assert.deepEqual(decoded.animations.map(({ id, durationMs }) => ({ id, durationMs })), [
|
||||
{ id: 0, durationMs: 700 },
|
||||
{ id: 158, durationMs: 633 },
|
||||
{ id: 159, durationMs: 1100 },
|
||||
]);
|
||||
assert.equal(decoded.colors.length, 3);
|
||||
assert.equal(decoded.textureWeights.length, 3);
|
||||
assert.deepEqual(decoded.colors[0].color.sequences[0].values[0], [
|
||||
0.9686275124549866,
|
||||
0.9921569228172302,
|
||||
0.3176470696926117,
|
||||
]);
|
||||
assert.deepEqual(decoded.colors.map((color) => color.alpha.sequences[0].values[0]), [
|
||||
32_767, 32_767, 32_767,
|
||||
]);
|
||||
assert.deepEqual(decoded.textureWeights[1].sequences[1], {
|
||||
timestamps: [0, 300, 633],
|
||||
values: [4_915, 1_966, 4_915],
|
||||
});
|
||||
assert.deepEqual(decoded.textures.map((texture) => texture.filename), [
|
||||
"SPELLS\\SHOCKWAVE10D.BLP",
|
||||
"SPELLS\\GENERICGLOW2B1.BLP",
|
||||
|
||||
Reference in new issue
Block a user