Files
healer-man/scripts/combat-effects/generate-manifest.mjs
T
2026-09-24 18:15:45 -04:00

973 lines
45 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { verifiedRuneWakerParticlePhases } from "./rom-particle-readiness.mjs";
const require = createRequire(import.meta.url);
const { MpqArchive } = require("stormlib-js");
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
function sha256(buffer) {
return createHash("sha256").update(buffer).digest("hex");
}
export function parseDbc(buffer, name = "DBC") {
if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer);
if (buffer.toString("ascii", 0, 4) !== "WDBC") throw new Error(`${name} is not WDBC data.`);
const recordCount = buffer.readUInt32LE(4);
const fieldCount = buffer.readUInt32LE(8);
const recordSize = buffer.readUInt32LE(12);
const stringSize = buffer.readUInt32LE(16);
const recordsEnd = 20 + recordCount * recordSize;
if (recordSize < fieldCount * 4 || recordsEnd + stringSize > buffer.length) {
throw new Error(`${name} has an unsupported or truncated schema.`);
}
const stringAt = (offset) => {
if (!offset || offset >= stringSize) return "";
const start = recordsEnd + offset;
const end = buffer.indexOf(0, start);
return buffer.toString("utf8", start, end < 0 ? buffer.length : end);
};
const row = (index) => {
const base = 20 + index * recordSize;
return {
uint: (field) => field < fieldCount ? buffer.readUInt32LE(base + field * 4) : 0,
int: (field) => field < fieldCount ? buffer.readInt32LE(base + field * 4) : 0,
float: (field) => field < fieldCount ? buffer.readFloatLE(base + field * 4) : 0,
string: (field) => stringAt(field < fieldCount ? buffer.readUInt32LE(base + field * 4) : 0),
};
};
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) },
uint16: { byteLength: 2, read: (buffer, offset) => buffer.readUInt16LE(offset) },
uint8: { byteLength: 1, read: (buffer, offset) => buffer.readUInt8(offset) },
vector2: {
byteLength: 8,
read: (buffer, offset) => [buffer.readFloatLE(offset), buffer.readFloatLE(offset + 4)],
},
vector3: {
byteLength: 12,
read: (buffer, offset) => [
buffer.readFloatLE(offset),
buffer.readFloatLE(offset + 4),
buffer.readFloatLE(offset + 8),
],
},
};
function assertM2Range(buffer, offset, byteLength, label) {
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(byteLength)
|| offset < 0 || byteLength < 0 || offset > buffer.length - byteLength) {
throw new Error(`${label} points outside the M2 buffer.`);
}
}
function readM2ArrayHeader(buffer, offset, label) {
assertM2Range(buffer, offset, 8, `${label} descriptor`);
return { count: buffer.readUInt32LE(offset), offset: buffer.readUInt32LE(offset + 4) };
}
function decodeM2Array(buffer, offset, valueType, label) {
const descriptor = readM2ArrayHeader(buffer, offset, label);
const byteLength = descriptor.count * valueType.byteLength;
if (!Number.isSafeInteger(byteLength)) throw new Error(`${label} is too large to decode.`);
if (descriptor.count) assertM2Range(buffer, descriptor.offset, byteLength, label);
return {
...descriptor,
values: Array.from({ length: descriptor.count }, (_, index) => (
valueType.read(buffer, descriptor.offset + index * valueType.byteLength)
)),
};
}
function decodeM2NestedArray(buffer, offset, valueType, label) {
const descriptor = readM2ArrayHeader(buffer, offset, label);
const byteLength = descriptor.count * 8;
if (!Number.isSafeInteger(byteLength)) throw new Error(`${label} is too large to decode.`);
if (descriptor.count) assertM2Range(buffer, descriptor.offset, byteLength, `${label} sequence descriptors`);
return {
...descriptor,
sequences: Array.from({ length: descriptor.count }, (_, index) => (
decodeM2Array(buffer, descriptor.offset + index * 8, valueType, `${label} sequence ${index}`)
)),
};
}
function decodeM2StringArray(buffer, offset, label) {
const descriptor = readM2ArrayHeader(buffer, offset, label);
if (!descriptor.count) return "";
assertM2Range(buffer, descriptor.offset, descriptor.count, label);
const bytes = buffer.subarray(descriptor.offset, descriptor.offset + descriptor.count);
const terminator = bytes.indexOf(0);
return bytes.toString("utf8", 0, terminator < 0 ? bytes.length : terminator);
}
function decodeM2Track(buffer, offset, valueType, label) {
assertM2Range(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 = decodeM2NestedArray(buffer, offset + 4, M2_VALUE_TYPES.uint32, `${label} timestamps`);
const values = decodeM2NestedArray(buffer, offset + 12, trackValueType, `${label} values`);
const firstTimestamps = timestamps.sequences[0]?.values ?? [];
const firstValues = values.sequences[0]?.values ?? [];
return {
kind: "animated",
interpolation,
globalSequence: buffer.readInt16LE(offset + 2),
timestamps,
values,
firstSequence: {
timestamps: firstTimestamps,
values: firstValues,
samples: Array.from({ length: Math.min(firstTimestamps.length, firstValues.length) }, (_, index) => ({
timeMs: firstTimestamps[index],
value: firstValues[index],
})),
},
};
}
function decodeM2ParticleLifetimeTrack(buffer, offset, valueType, label) {
const timestamps = decodeM2Array(buffer, offset, M2_VALUE_TYPES.uint16, `${label} timestamps`);
const values = decodeM2Array(buffer, offset + 8, valueType, `${label} values`);
return {
kind: "particle-lifetime",
timestamps,
values,
samples: Array.from({ length: Math.min(timestamps.values.length, values.values.length) }, (_, index) => ({
timestamp: timestamps.values[index],
normalizedTime: timestamps.values[index] / 32_767,
value: values.values[index],
})),
};
}
/** Decode render-neutral descriptors from the WotLK 3.3.5 (version 264) MD20 layout. */
export function decodeWotlkM2Effects(buffer, name = "M2") {
if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer);
const magic = buffer.toString("ascii", 0, 4);
if (magic !== "MD20" || buffer.length < 304 || buffer.readUInt32LE(4) !== 264) {
throw new Error(`${name} is not a supported WotLK 3.3.5 MD20.`);
}
const version = buffer.readUInt32LE(4);
const animationCount = buffer.readUInt32LE(28);
const animationOffset = buffer.readUInt32LE(32);
const textureCount = buffer.readUInt32LE(80);
const textureOffset = buffer.readUInt32LE(84);
const ribbonCount = buffer.readUInt32LE(288);
const ribbonOffset = buffer.readUInt32LE(292);
const particleCount = buffer.readUInt32LE(296);
const particleOffset = buffer.readUInt32LE(300);
const animationRecordSize = 64;
const textureRecordSize = 16;
const ribbonRecordSize = 176;
const particleRecordSize = 476;
if (animationCount) assertM2Range(buffer, animationOffset, animationCount * animationRecordSize, `${name} animation records`);
if (textureCount) assertM2Range(buffer, textureOffset, textureCount * textureRecordSize, `${name} texture records`);
if (ribbonCount) assertM2Range(buffer, ribbonOffset, ribbonCount * ribbonRecordSize, `${name} ribbon records`);
if (particleCount) assertM2Range(buffer, particleOffset, particleCount * particleRecordSize, `${name} particle records`);
const animations = Array.from({ length: animationCount }, (_, index) => {
const offset = animationOffset + index * animationRecordSize;
return {
index,
id: buffer.readUInt16LE(offset),
variationIndex: buffer.readUInt16LE(offset + 2),
durationMs: buffer.readUInt32LE(offset + 4),
moveSpeed: buffer.readFloatLE(offset + 8),
flags: buffer.readUInt32LE(offset + 12),
};
});
const durationMs = animations.reduce((maximum, animation) => Math.max(maximum, animation.durationMs), 0);
const textures = Array.from({ length: textureCount }, (_, index) => {
const offset = textureOffset + index * textureRecordSize;
return {
type: buffer.readUInt32LE(offset),
flags: buffer.readUInt32LE(offset + 4),
filename: decodeM2StringArray(buffer, offset + 8, `${name} texture ${index} filename`),
};
});
const animatedTrackOffsets = {
emissionSpeed: 52,
speedVariation: 72,
verticalRange: 92,
horizontalRange: 112,
gravity: 132,
lifespan: 152,
emissionRate: 176,
emissionAreaLength: 200,
emissionAreaWidth: 220,
zSource: 240,
};
const particles = Array.from({ length: particleCount }, (_, index) => {
const offset = particleOffset + index * particleRecordSize;
const tracks = Object.fromEntries(Object.entries(animatedTrackOffsets).map(([track, relativeOffset]) => [
track,
decodeM2Track(buffer, offset + relativeOffset, M2_VALUE_TYPES.float32, `${name} particle ${index} ${track}`),
]));
tracks.color = decodeM2ParticleLifetimeTrack(
buffer, offset + 260, M2_VALUE_TYPES.vector3, `${name} particle ${index} color`,
);
tracks.alpha = decodeM2ParticleLifetimeTrack(
buffer, offset + 276, M2_VALUE_TYPES.uint16, `${name} particle ${index} alpha`,
);
tracks.scale = decodeM2ParticleLifetimeTrack(
buffer, offset + 292, M2_VALUE_TYPES.vector2, `${name} particle ${index} scale`,
);
tracks.headCell = decodeM2ParticleLifetimeTrack(
buffer, offset + 316, M2_VALUE_TYPES.uint16, `${name} particle ${index} head cell`,
);
tracks.tailCell = decodeM2ParticleLifetimeTrack(
buffer, offset + 332, M2_VALUE_TYPES.uint16, `${name} particle ${index} tail cell`,
);
tracks.enabled = decodeM2Track(
buffer, offset + 456, M2_VALUE_TYPES.uint8, `${name} particle ${index} enabled`,
);
return {
id: buffer.readUInt32LE(offset),
flags: buffer.readUInt32LE(offset + 4),
position: M2_VALUE_TYPES.vector3.read(buffer, offset + 8),
parentBone: buffer.readUInt16LE(offset + 20),
textureIndex: buffer.readUInt16LE(offset + 22),
geometryModel: decodeM2StringArray(buffer, offset + 24, `${name} particle ${index} geometry model`),
recursionModel: decodeM2StringArray(buffer, offset + 32, `${name} particle ${index} recursion model`),
blendMode: buffer.readUInt8(offset + 40),
emitterType: buffer.readUInt8(offset + 41),
particleColorIndex: buffer.readUInt16LE(offset + 42),
particleType: buffer.readUInt8(offset + 44),
headOrTail: buffer.readUInt8(offset + 45),
textureTileRotation: buffer.readUInt16LE(offset + 46),
textureRows: buffer.readUInt16LE(offset + 48),
textureColumns: buffer.readUInt16LE(offset + 50),
lifespanVariation: buffer.readFloatLE(offset + 172),
emissionRateVariation: buffer.readFloatLE(offset + 196),
scaleVariation: M2_VALUE_TYPES.vector2.read(buffer, offset + 308),
tailLength: buffer.readFloatLE(offset + 348),
tracks,
samples: Object.fromEntries([
...Object.keys(animatedTrackOffsets).map((track) => [track, tracks[track].firstSequence.samples]),
...["color", "alpha", "scale"].map((track) => [track, tracks[track].samples]),
]),
};
});
const ribbons = Array.from({ length: ribbonCount }, (_, index) => {
const offset = ribbonOffset + index * ribbonRecordSize;
return {
id: buffer.readUInt32LE(offset),
parentBone: buffer.readUInt32LE(offset + 4),
position: M2_VALUE_TYPES.vector3.read(buffer, offset + 8),
textureIndices: decodeM2Array(buffer, offset + 20, M2_VALUE_TYPES.uint16, `${name} ribbon ${index} textures`),
materialIndices: decodeM2Array(buffer, offset + 28, M2_VALUE_TYPES.uint16, `${name} ribbon ${index} materials`),
edgesPerSecond: buffer.readFloatLE(offset + 116),
edgeLifetime: buffer.readFloatLE(offset + 120),
gravity: buffer.readFloatLE(offset + 124),
textureRows: buffer.readUInt16LE(offset + 128),
textureColumns: buffer.readUInt16LE(offset + 130),
priorityPlane: buffer.readInt16LE(offset + 172),
tracks: {
color: decodeM2Track(buffer, offset + 36, M2_VALUE_TYPES.vector3, `${name} ribbon ${index} color`),
alpha: decodeM2Track(buffer, offset + 56, M2_VALUE_TYPES.uint16, `${name} ribbon ${index} alpha`),
heightAbove: decodeM2Track(buffer, offset + 76, M2_VALUE_TYPES.float32, `${name} ribbon ${index} height above`),
heightBelow: decodeM2Track(buffer, offset + 96, M2_VALUE_TYPES.float32, `${name} ribbon ${index} height below`),
textureSlot: decodeM2Track(buffer, offset + 132, M2_VALUE_TYPES.uint16, `${name} ribbon ${index} texture slot`),
visibility: decodeM2Track(buffer, offset + 152, M2_VALUE_TYPES.uint8, `${name} ribbon ${index} visibility`),
},
};
});
return { magic, version, durationMs, animations, textures, particles, ribbons };
}
function tableById(table, decode) {
const result = new Map();
for (let index = 0; index < table.recordCount; index += 1) {
const row = table.row(index);
result.set(row.uint(0), decode(row));
}
return result;
}
function collectAbilitySpellIds() {
const requested = new Map();
const add = (source, id) => {
if (!Number.isInteger(id) || id <= 0) return;
requested.set(`${source}:${id}`, { source, spellId: id });
};
const abilityCatalog = fs.readFileSync(path.join(projectRoot, "src", "game", "abilityCatalog.ts"), "utf8");
for (const match of abilityCatalog.matchAll(/dbcSpellId:\s*(\d+)/g)) add("wow335", Number(match[1]));
const wow = JSON.parse(fs.readFileSync(path.join(projectRoot, "src", "game", "wow335AbilityData.generated.json"), "utf8"));
for (const chains of Object.values(wow.classes ?? {})) {
for (const chain of chains) for (const rank of chain.ranks ?? []) add("wow335", Number(rank.spellId));
}
const coa = JSON.parse(fs.readFileSync(path.join(projectRoot, "src", "game", "generated", "coaAbilityRuntimeCatalog.json"), "utf8"));
for (const abilities of Object.values(coa.abilitiesByClass ?? {})) {
for (const ability of abilities) {
add("ascension", Number(ability.dbcSpellId));
for (const rank of ability.ranks ?? []) add("ascension", Number(rank.spellId));
}
}
const rom = JSON.parse(fs.readFileSync(path.join(projectRoot, "src", "game", "romPlayerCatalog.generated.json"), "utf8"));
const runewakerImageBySpellId = new Map();
for (const skill of rom.skills ?? []) {
add("runewaker", Number(skill.sourceSkillId));
if (Number(skill.source?.imageId) > 0) runewakerImageBySpellId.set(Number(skill.sourceSkillId), Number(skill.source.imageId));
for (const rank of skill.ranks ?? []) {
add("runewaker", Number(rank.sourceSkillId));
const imageId = Number(rank.source?.imageId ?? skill.source?.imageId);
if (imageId > 0) runewakerImageBySpellId.set(Number(rank.sourceSkillId), imageId);
}
}
const generatedRoot = path.join(projectRoot, "src", "game", "generated");
for (const name of fs.readdirSync(generatedRoot).filter((entry) => entry.endsWith("Population.generated.ts"))) {
const source = fs.readFileSync(path.join(generatedRoot, name), "utf8");
for (const match of source.matchAll(/"spellId":\s*(\d+)/g)) {
const spellId = Number(match[1]);
add(spellId >= 490_000 && spellId < 600_000 ? "runewaker" : "ascension", spellId);
}
}
const dungeonCatalog = JSON.parse(fs.readFileSync(
path.join(generatedRoot, "dungeonCampaignCatalog.json"),
"utf8",
));
for (const dungeon of dungeonCatalog.definitions ?? []) {
for (const entity of Object.values(dungeon.entities ?? {})) {
if (entity.kind !== "boss") continue;
for (const attack of entity.combat?.attacks ?? []) {
if (!attack.effect) continue;
const spellId = Number(attack.spellId);
add(spellId >= 490_000 && spellId < 600_000 ? "runewaker" : "wow335", spellId);
}
}
}
return { requested: [...requested.values()], runewakerImageBySpellId };
}
function soundEntry(table, id) {
if (!id) return [];
for (let index = 0; index < table.recordCount; index += 1) {
const row = table.row(index);
if (row.uint(0) !== id) continue;
const directory = row.string(23).replaceAll("\\", "/").replace(/\/$/, "");
const volume = Number(row.float(24).toFixed(4));
const minimumDistance = Number(row.float(26).toFixed(3));
const maximumDistance = Number(row.float(27).toFixed(3));
return Array.from({ length: 10 }, (_, fileIndex) => {
const file = row.string(3 + fileIndex).replaceAll("\\", "/");
if (!file) return null;
return {
sourcePath: directory ? `${directory}/${file}` : file,
weight: Math.max(1, row.uint(13 + fileIndex)),
volume: volume > 0 ? volume : 1,
...(minimumDistance > 0 ? { minimumDistance } : {}),
...(maximumDistance > 0 ? { maximumDistance } : {}),
};
}).filter(Boolean);
}
return [];
}
function buildWowEntries(source, requestedIds, tables) {
const spell = parseDbc(tables.Spell, `${source} Spell.dbc`);
const visual = tableById(parseDbc(tables.SpellVisual, `${source} SpellVisual.dbc`), (row) => ({
precast: row.uint(1), cast: row.uint(2), impact: row.uint(3), state: row.uint(4), stateDone: row.uint(5),
channel: row.uint(6), missileModel: row.uint(8), missileSound: row.uint(11), animationSound: row.uint(12),
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, decodeWotlkSpellVisualKitRow);
const effectNames = tableById(parseDbc(tables.SpellVisualEffectName, `${source} SpellVisualEffectName.dbc`), (row) => ({
name: row.string(1),
model: row.string(2).replaceAll("\\", "/"),
scale: row.float(4) || 1,
}));
const attachments = new Map();
const attachTable = parseDbc(tables.SpellVisualKitModelAttach, `${source} SpellVisualKitModelAttach.dbc`);
for (let index = 0; index < attachTable.recordCount; index += 1) {
const row = attachTable.row(index);
const records = attachments.get(row.uint(1)) ?? [];
records.push({ effectId: row.uint(2), attachment: `attachment:${row.uint(3)}` });
attachments.set(row.uint(1), records);
}
const soundTable = parseDbc(tables.SoundEntries, `${source} SoundEntries.dbc`);
const kitAssets = (kitId) => {
const kit = kits.get(kitId);
if (!kit) return [];
const attached = new Map((attachments.get(kitId) ?? []).map((entry) => [entry.effectId, entry.attachment]));
return [...new Set([...kit.effects, ...(attachments.get(kitId) ?? []).map((entry) => entry.effectId)])]
.map((effectId) => {
const effect = effectNames.get(effectId);
if (!effect?.model) return null;
return {
model: effect.model,
...(attached.get(effectId) ? { attachment: attached.get(effectId) } : {}),
...(effect.scale !== 1 ? { scale: Number(effect.scale.toFixed(4)) } : {}),
};
}).filter(Boolean);
};
const soundsForKit = (kitId) => soundEntry(soundTable, kits.get(kitId)?.soundId ?? 0);
const spellRows = new Map();
const shift = spell.fieldCount >= 239 ? 6 : 0;
const shifted = (field) => field >= 77 ? field + shift : field;
const wanted = new Set(requestedIds);
for (let index = 0; index < spell.recordCount; index += 1) {
const row = spell.row(index);
const id = row.uint(0);
if (wanted.has(id)) spellRows.set(id, [row.uint(shifted(131)), row.uint(shifted(132))].filter(Boolean));
}
const entries = [];
for (const spellId of requestedIds) {
const visualIds = spellRows.get(spellId) ?? [];
const phases = {};
const sounds = [];
const addAssets = (phase, assets) => {
const usable = assets.filter(Boolean);
if (usable.length) phases[phase] = [...(phases[phase] ?? []), ...usable];
};
for (const visualId of visualIds) {
const row = visual.get(visualId);
if (!row) continue;
for (const kitId of [row.precast, row.cast]) addAssets("cast-start", kitAssets(kitId));
if (row.missileModel) {
const missile = effectNames.get(row.missileModel);
if (missile?.model) addAssets("release", [{ model: missile.model, scale: missile.scale }]);
}
for (const kitId of [row.impact, row.casterImpact, row.targetImpact]) addAssets("impact", kitAssets(kitId));
addAssets("aura-start", kitAssets(row.state));
addAssets("aura-end", kitAssets(row.stateDone));
addAssets("tick", kitAssets(row.channel));
for (const kitId of [row.instantArea, row.impactArea, row.persistentArea]) addAssets("impact", kitAssets(kitId));
for (const kitId of [row.precast, row.cast, row.impact, row.state, row.channel, row.instantArea, row.impactArea, row.persistentArea]) {
sounds.push(...soundsForKit(kitId));
}
sounds.push(...soundEntry(soundTable, row.missileSound), ...soundEntry(soundTable, row.animationSound));
}
for (const [phase, assets] of Object.entries(phases)) {
phases[phase] = [...new Map(assets.map((asset) => [`${asset.model}:${asset.attachment ?? ""}`, asset])).values()];
}
const uniqueSounds = [...new Map(sounds.map((sound) => [sound.sourcePath, sound])).values()];
if (!visualIds.length && !uniqueSounds.length) continue;
entries.push({
key: `${source}:${spellId}`,
source,
spellId,
visualIds,
...(Object.keys(phases).length ? { phases } : {}),
...(uniqueSounds.length ? { sounds: uniqueSounds } : {}),
});
}
return entries;
}
function readNullSeparatedPaths(record) {
const values = [];
let start = 0;
for (let offset = 0; offset <= record.length; offset += 1) {
if (offset < record.length && record[offset] !== 0) continue;
if (offset - start >= 4) {
const value = record.subarray(start, offset).toString("latin1").trim();
if (/^[\x20-\x7e]+$/.test(value) && /[\\/]/.test(value)) values.push(value.replaceAll("\\", "/"));
}
start = offset + 1;
}
return values;
}
export function parseRunewakerImageRecords(buffer, requestedImageIds) {
const count = buffer.readInt32LE(132);
const recordSize = 3712;
const dataStart = 140;
if (count <= 0 || dataStart + count * recordSize > buffer.length) throw new Error("ImageObject.DB has an incompatible layout.");
const wanted = new Set(requestedImageIds);
const result = new Map();
for (let index = 0; index < count; index += 1) {
const offset = dataStart + index * recordSize;
const id = buffer.readInt32LE(offset);
if (!wanted.has(id)) continue;
result.set(id, readNullSeparatedPaths(buffer.subarray(offset, offset + recordSize)));
}
return result;
}
export function runewakerSoundPathsForModel(modelPath, resourceRoot) {
if (!/^model\/fx\/.+\.(?:ros|ras)$/i.test(modelPath) || modelPath.includes("..")) return [];
const soundPath = modelPath.replace(/^model\//i, "sound/");
const stem = soundPath.replace(/\.(?:ros|ras)$/i, "");
for (const candidate of [stem, soundPath]) {
for (const extension of ["mp3", "wav", "ogg"]) {
const sourcePath = `${candidate}.${extension}`;
if (fs.existsSync(path.join(resourceRoot, ...sourcePath.split("/")))) return [sourcePath];
}
}
return [];
}
// These one-shot cues are direct 3D audio events (ID 43) in the named ROS
// motions. The client places the impact models through the skill's MagicObject
// component image, while the projectile model comes from ImageObject.DB.
// The projectile's embedded cue differs from the parallel sound/fx filename.
const verifiedRuneWakerEventSounds = [
{
spellId: 491147, phase: "impact",
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",
model: "model/fx/skill/ice/act_icecone01_f.ros",
sourcePath: "sound/fx/skill/ice/icenew/act_icecone01_f.ros.mp3",
},
{
spellId: 490256, phase: "impact",
model: "model/fx/skill/ice/act_riptide_t_freeze_hit.ros",
sourcePath: "sound/fx/skill/ice/act_riptide_t_freeze_hit.mp3",
},
];
function attachVerifiedRuneWakerEventSounds(entries) {
const bySpellId = new Map(entries.map((entry) => [entry.spellId, entry]));
for (const { spellId, phase, model, sourcePath } of verifiedRuneWakerEventSounds) {
const entry = bySpellId.get(spellId);
if (!entry?.phases?.[phase]?.some((asset) => asset.model?.toLowerCase() === model)) continue;
if (entry.sounds?.some((sound) => sound.phase === phase && sound.sourcePath?.toLowerCase() === sourcePath)) continue;
(entry.sounds ??= []).push({ sourcePath, phase, weight: 1, volume: 1 });
}
}
function runewakerImageIdsFromMagicCollect(buffer, spellIds) {
const count = buffer.readInt32LE(132);
const recordSize = 1032;
const dataStart = 140;
const wanted = new Set(spellIds);
const result = new Map();
for (let index = 0; index < count; index += 1) {
const offset = dataStart + index * recordSize;
const spellId = buffer.readInt32LE(offset);
if (wanted.has(spellId)) result.set(spellId, buffer.readInt32LE(offset + 32));
}
return result;
}
// The cast's MagicCollect ImageID omits the target-side ExtendACT. These two
// component links are verified against MagicObject.ImageID and ImageObject.DB
// before the target model is added to the combat presentation manifest.
const verifiedRuneWakerTargetEffects = [
{ spellId: 491147, componentId: 501543, imageId: 573048,
model: "model/fx/skill/holy/act_benedictory_heal_t_down.ros", point: "p_down" },
{ spellId: 490256, componentId: 620408, imageId: 572062,
model: "model/fx/skill/ice/act_riptide_t_freeze_hit.ros", point: "freeze_hit" },
];
function verifiedRunewakerTargetModels(magicCollect, magicObject, imageObject, spellIds) {
const wanted = verifiedRuneWakerTargetEffects.filter((effect) => spellIds.includes(effect.spellId));
if (!wanted.length) return new Map();
const collectOffsets = new Map();
for (let offset = 140; offset + 1032 <= magicCollect.length; offset += 1032) {
const spellId = magicCollect.readInt32LE(offset);
if (wanted.some((effect) => effect.spellId === spellId)) collectOffsets.set(spellId, offset);
}
const componentImages = new Map();
for (let offset = 140; offset + 992 <= magicObject.length; offset += 992) {
const componentId = magicObject.readInt32LE(offset);
if (wanted.some((effect) => effect.componentId === componentId)) {
componentImages.set(componentId, magicObject.readInt32LE(offset + 0x20));
}
}
const componentImageFields = new Map();
for (let offset = 140; offset + 3712 <= imageObject.length; offset += 3712) {
const imageId = imageObject.readInt32LE(offset);
if (!wanted.some((effect) => effect.imageId === imageId)) continue;
const textField = (fieldOffset, length) => imageObject.subarray(
offset + fieldOffset, offset + fieldOffset + length,
).toString("latin1").split("\0", 1)[0].replaceAll("\\", "/").toLowerCase();
componentImageFields.set(imageId, {
extendAct: textField(2132, 128),
extendPoint: textField(2260, 32),
});
}
for (const effect of wanted) {
const collectOffset = collectOffsets.get(effect.spellId);
const componentIds = collectOffset === undefined ? [] : Array.from(
{ length: (0x2c0 - 0x180) / 4 + 1 }, (_, index) =>
magicCollect.readInt32LE(collectOffset + 0x180 + index * 4),
);
if (!componentIds.includes(effect.componentId)
|| componentImages.get(effect.componentId) !== effect.imageId
|| componentImageFields.get(effect.imageId)?.extendAct !== effect.model
|| componentImageFields.get(effect.imageId)?.extendPoint !== effect.point) {
throw new Error(`RuneWaker target media source link changed for spell ${effect.spellId}`);
}
}
return new Map(wanted.map((effect) => [effect.spellId, effect]));
}
function attachVerifiedRuneWakerParticles(entries) {
const archivePath = path.join(projectRoot, "public/assets/game/combat-effects/original/runewaker/manifest.json");
if (!fs.existsSync(archivePath)) return;
const archive = JSON.parse(fs.readFileSync(archivePath, "utf8"));
const decoded = new Map((archive.results ?? [])
.filter((result) => (result.rendererReady || result.partialRendererReady) && result.graphUrl)
.map((result) => [result.model.toLowerCase(), result]));
const bySpellId = new Map(entries.map((entry) => [entry.spellId, entry]));
for (const { spellId, phase, model, scale, loopCastSound, partialVisual } of verifiedRuneWakerParticlePhases) {
const entry = bySpellId.get(spellId);
const asset = entry?.phases?.[phase]?.find((candidate) => (
candidate.model.toLowerCase() === model
));
const result = decoded.get(model);
if (!asset || !result || (partialVisual ? !result.partialRendererReady : !result.rendererReady)) continue;
const graphPath = path.join(projectRoot, "public", result.graphUrl.slice(1));
if (!fs.existsSync(graphPath)) continue;
const graph = JSON.parse(fs.readFileSync(graphPath, "utf8"));
if (graph.sourceModel !== model
|| (partialVisual ? !graph.partialRendererReady : !graph.rendererReady)) continue;
asset.effectKind = "runewaker-particles";
asset.effectGraphUrl = result.graphUrl;
if (partialVisual) asset.visualCompleteness = "partial";
if (scale) asset.scale = scale;
if (loopCastSound) for (const sound of entry.sounds ?? []) {
if (sound.phase === "cast-start"
&& sound.sourcePath === "sound/fx/skill/holy/act_casting_c_hand.wav") {
sound.loopDuringCast = true;
}
}
}
}
function buildRunewakerEntries(spellIds, imageBySpellId, dataRoot) {
const resourceRoot = path.dirname(dataRoot);
const magicCollect = fs.readFileSync(path.join(dataRoot, "magiccollectobject.db"));
const magicObject = fs.readFileSync(path.join(dataRoot, "magicobject.db"));
const imageObject = fs.readFileSync(path.join(dataRoot, "imageobject.db"));
const targetEffects = verifiedRunewakerTargetModels(magicCollect, magicObject, imageObject, spellIds);
const collectedImages = runewakerImageIdsFromMagicCollect(magicCollect, spellIds);
for (const [spellId, imageId] of collectedImages) if (!imageBySpellId.has(spellId)) imageBySpellId.set(spellId, imageId);
const requestedImages = [...new Set([
...spellIds.map((id) => imageBySpellId.get(id)).filter(Boolean),
...[...targetEffects.values()].map((effect) => effect.imageId),
])];
const images = parseRunewakerImageRecords(imageObject, requestedImages);
const entries = [];
for (const spellId of spellIds) {
const imageId = imageBySpellId.get(spellId);
if (!imageId) continue;
const targetEffect = targetEffects.get(spellId);
const targetImagePaths = targetEffect ? images.get(targetEffect.imageId) ?? [] : [];
const targetModel = targetEffect && targetImagePaths.find((candidate) => (
candidate.toLowerCase() === targetEffect.model
));
if (targetEffect && !targetModel) {
throw new Error(`RuneWaker target ImageObject changed for spell ${spellId}`);
}
const paths = [...(images.get(imageId) ?? []), ...(targetModel ? [targetModel] : [])];
const phases = {};
const sounds = [];
for (const sourcePath of paths) {
if (/\.(?:wav|ogg|mp3)$/i.test(sourcePath)) {
sounds.push({ sourcePath, weight: 1, volume: 1 });
continue;
}
if (!/\.(?:ros|ras)$/i.test(sourcePath)) continue;
const lower = sourcePath.toLowerCase();
const filename = path.posix.basename(lower);
const phase = /(?:^|_)t(?:_|\.)|explode|impact/.test(filename)
|| (/hit/.test(filename) && !/(?:^|_)f(?:_|\.)/.test(filename))
? "impact"
: /(?:^|_)f(?:_|\.)|fly|missile/.test(filename)
? "release"
: /buff|aura|extend/.test(filename)
? "aura-start"
: "cast-start";
(phases[phase] ??= []).push({ model: sourcePath });
for (const soundPath of runewakerSoundPathsForModel(sourcePath, resourceRoot)) {
sounds.push({ sourcePath: soundPath, phase, weight: 1, volume: 1 });
}
}
for (const [phase, assets] of Object.entries(phases)) {
phases[phase] = [...new Map(assets.map((asset) => [asset.model.toLowerCase(), asset])).values()];
}
const uniqueSounds = [...new Map(sounds.map((sound) => [`${sound.phase ?? "any"}:${sound.sourcePath.toLowerCase()}`, sound])).values()];
if (!Object.keys(phases).length && !uniqueSounds.length) continue;
entries.push({
key: `runewaker:${spellId}`,
source: "runewaker",
spellId,
imageId,
phases,
...(uniqueSounds.length ? { sounds: uniqueSounds } : {}),
});
}
attachVerifiedRuneWakerEventSounds(entries);
attachVerifiedRuneWakerParticles(entries);
return entries;
}
function extractWowTables(archivePath) {
const archive = MpqArchive.open(archivePath);
try {
const extract = (name) => archive.extractFile(`DBFilesClient\\${name}.dbc`);
return Object.fromEntries([
"Spell", "SpellVisual", "SpellVisualKit", "SpellVisualEffectName", "SpellVisualKitModelAttach", "SoundEntries",
].map((name) => [name, extract(name)]));
} finally {
archive.close();
}
}
function extractedAscensionTables(spellPath, supportRoot, baseSoundEntries) {
const read = (name) => fs.readFileSync(path.join(supportRoot, `${name}.dbc`));
return {
Spell: fs.readFileSync(spellPath),
SpellVisual: read("SpellVisual"),
SpellVisualKit: read("SpellVisualKit"),
SpellVisualEffectName: read("SpellVisualEffectName"),
SpellVisualKitModelAttach: read("SpellVisualKitModelAttach"),
SoundEntries: fs.existsSync(path.join(supportRoot, "SoundEntries.dbc")) ? read("SoundEntries") : 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",
];
return names
.map((name) => path.join(dataRoot, name))
.filter((archivePath) => fs.existsSync(archivePath) && fs.statSync(archivePath).isFile())
.map((archivePath) => ({ archivePath, archive: MpqArchive.open(archivePath) }));
}
function packageAudioAssets(entries, options) {
const outputRoot = path.resolve(options.audioOutput
?? path.join(projectRoot, "public", "assets", "game", "combat-effects", "audio"));
const publicPrefix = "/assets/game/combat-effects/audio";
const wowDataRoot = path.resolve(options.wowDataRoot
?? path.join(projectRoot, "..", "wow335a", "Data"));
const runewakerResourceRoot = path.resolve(options.runewakerResourceRoot
?? path.join(projectRoot, "..", "Runewaker", "Resource"));
const archives = entries.some((entry) => entry.source !== "runewaker")
? openedWowAudioArchives(wowDataRoot)
: [];
const sourceCache = new Map();
const missing = [];
const packagedFiles = new Set();
fs.mkdirSync(outputRoot, { recursive: true });
const extractSource = (sourcePath, source) => {
const cacheKey = `${source}:${sourcePath.toLowerCase()}`;
if (sourceCache.has(cacheKey)) return sourceCache.get(cacheKey);
let buffer = null;
if (source === "runewaker") {
const candidate = path.join(runewakerResourceRoot, ...sourcePath.split("/"));
if (fs.existsSync(candidate)) buffer = fs.readFileSync(candidate);
} else {
const archiveName = sourcePath.replaceAll("/", "\\");
// 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);
return buffer;
};
try {
for (const entry of entries) {
for (const sound of entry.sounds ?? []) {
const buffer = extractSource(sound.sourcePath, entry.source);
if (!buffer?.length) {
missing.push(`${entry.source}:${sound.sourcePath}`);
continue;
}
const extension = path.extname(sound.sourcePath).toLowerCase() || ".wav";
const fileName = `${sha256(buffer).slice(0, 20)}${extension}`;
const outputPath = path.join(outputRoot, fileName);
if (!fs.existsSync(outputPath)) fs.writeFileSync(outputPath, buffer);
packagedFiles.add(fileName);
sound.url = `${publicPrefix}/${fileName}`;
}
}
} finally {
for (const { archive } of archives) archive.close();
}
return {
referenced: sourceCache.size,
packaged: packagedFiles.size,
missing: [...new Set(missing)].sort(),
};
}
export function generateCombatEffectManifest(options = {}) {
const wowArchive = path.resolve(options.wowArchive ?? path.join(projectRoot, "..", "wow335a", "Data", "enUS", "patch-enUS.MPQ"));
const ascensionRoot = path.resolve(options.ascensionRoot ?? path.join(projectRoot, "..", "LadiksMPQEditor", "client-current"));
const ascensionSpell = path.resolve(options.ascensionSpell ?? path.join(ascensionRoot, "area-52", "patch-D", "DBFilesClient", "Spell.dbc"));
const ascensionSupport = path.resolve(options.ascensionSupport ?? path.join(ascensionRoot, "patch-S", "DBFilesClient"));
const runewakerData = path.resolve(options.runewakerData ?? path.join(projectRoot, "..", "Runewaker", "Resource", "data"));
const { requested, runewakerImageBySpellId } = collectAbilitySpellIds();
const requestedBySource = requested.reduce((groups, entry) => {
(groups[entry.source] ??= []).push(entry);
return groups;
}, {});
const wowTables = extractWowTables(wowArchive);
const wowEntries = buildWowEntries("wow335", (requestedBySource.wow335 ?? []).map((entry) => entry.spellId), wowTables);
const ascensionTables = extractedAscensionTables(ascensionSpell, ascensionSupport, wowTables.SoundEntries);
const ascensionEntries = buildWowEntries("ascension", (requestedBySource.ascension ?? []).map((entry) => entry.spellId), ascensionTables);
const runewakerEntries = buildRunewakerEntries(
(requestedBySource.runewaker ?? []).map((entry) => entry.spellId),
runewakerImageBySpellId,
runewakerData,
);
const entries = [...wowEntries, ...ascensionEntries, ...runewakerEntries]
.sort((left, right) => left.source.localeCompare(right.source) || left.spellId - right.spellId);
const audio = options.packageAudio
? packageAudioAssets(entries, { ...options, runewakerResourceRoot: path.dirname(runewakerData) })
: { referenced: new Set(entries.flatMap((entry) => (entry.sounds ?? []).map((sound) => `${entry.source}:${sound.sourcePath}`))).size, packaged: 0, missing: [] };
const mapped = new Set(entries.map((entry) => entry.key));
const fallbackKeys = requested.map((entry) => `${entry.source}:${entry.spellId}`).filter((key) => !mapped.has(key));
return {
schemaVersion: 1,
generatedAt: null,
sources: [
{ kind: "wow335", path: path.relative(projectRoot, wowArchive).replaceAll("\\", "/"), sha256: sha256(wowTables.Spell) },
{ kind: "ascension", path: path.relative(projectRoot, ascensionSpell).replaceAll("\\", "/"), sha256: sha256(ascensionTables.Spell) },
{ kind: "runewaker", path: path.relative(projectRoot, runewakerData).replaceAll("\\", "/"), sha256: sha256(fs.readFileSync(path.join(runewakerData, "imageobject.db"))) },
],
coverage: {
requested: requested.length,
authentic: entries.length,
fallback: fallbackKeys.length,
fallbackKeys,
audio,
unsupportedConstructs: [
"WotLK event-driven child emitters use source-styled pooled fallbacks",
"RuneWaker native shader programs outside decoded blend/billboard descriptors use source-styled pooled fallbacks",
],
},
entries,
};
}
export function updateRunewakerCombatEffectManifest(options = {}) {
const output = path.resolve(options.output ?? path.join(projectRoot, "public", "assets", "game", "combat-effects", "manifest.json"));
const previous = JSON.parse(fs.readFileSync(output, "utf8"));
const runewakerData = path.resolve(options.runewakerData ?? path.join(projectRoot, "..", "Runewaker", "Resource", "data"));
const { requested, runewakerImageBySpellId } = collectAbilitySpellIds();
const runewakerEntries = buildRunewakerEntries(
requested.filter((entry) => entry.source === "runewaker").map((entry) => entry.spellId),
runewakerImageBySpellId,
runewakerData,
);
const audio = options.packageAudio
? packageAudioAssets(runewakerEntries, { ...options, runewakerResourceRoot: path.dirname(runewakerData) })
: { missing: [] };
const entries = [...previous.entries.filter((entry) => entry.source !== "runewaker"), ...runewakerEntries]
.sort((left, right) => left.source.localeCompare(right.source) || left.spellId - right.spellId);
const mapped = new Set(entries.map((entry) => entry.key));
const oldRunewakerRequested = previous.entries.filter((entry) => entry.source === "runewaker").length
+ previous.coverage.fallbackKeys.filter((key) => key.startsWith("runewaker:")).length;
const runewakerRequested = requested.filter((entry) => entry.source === "runewaker");
const fallbackKeys = [
...previous.coverage.fallbackKeys.filter((key) => !key.startsWith("runewaker:")),
...runewakerRequested.map((entry) => `runewaker:${entry.spellId}`).filter((key) => !mapped.has(key)),
];
const soundPaths = new Set(entries.flatMap((entry) => (entry.sounds ?? []).map((sound) => `${entry.source}:${sound.sourcePath}`)));
const audioUrls = new Set(entries.flatMap((entry) => (entry.sounds ?? []).map((sound) => sound.url).filter(Boolean)));
return {
...previous,
sources: previous.sources.map((source) => source.kind === "runewaker"
? { kind: "runewaker", path: path.relative(projectRoot, runewakerData).replaceAll("\\", "/"), sha256: sha256(fs.readFileSync(path.join(runewakerData, "imageobject.db"))) }
: source),
coverage: {
...previous.coverage,
requested: previous.coverage.requested - oldRunewakerRequested + runewakerRequested.length,
authentic: entries.length,
fallback: fallbackKeys.length,
fallbackKeys,
audio: { referenced: soundPaths.size, packaged: audioUrls.size, missing: audio.missing },
},
entries,
};
}
function parseArguments(arguments_) {
const options = {};
for (let index = 0; index < arguments_.length; index += 1) {
const argument = arguments_[index];
if (argument === "--audit") options.audit = true;
else if (argument === "--package-audio") options.packageAudio = true;
else if (argument === "--runewaker-only") options.runewakerOnly = true;
else if (argument === "--output") options.output = arguments_[++index];
else if (argument === "--wow-archive") options.wowArchive = arguments_[++index];
else if (argument === "--ascension-root") options.ascensionRoot = arguments_[++index];
else if (argument === "--runewaker-data") options.runewakerData = arguments_[++index];
else throw new Error(`Unknown argument: ${argument}`);
}
return options;
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const options = parseArguments(process.argv.slice(2));
const output = path.resolve(options.output ?? path.join(projectRoot, "public", "assets", "game", "combat-effects", "manifest.json"));
const manifest = options.runewakerOnly
? updateRunewakerCombatEffectManifest(options)
: generateCombatEffectManifest(options);
if (options.audit && manifest.coverage.audio.missing.length) {
throw new Error(`Combat effects audit failed: ${manifest.coverage.audio.missing.length} referenced audio files could not be packaged.`);
}
if (!options.audit) {
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, `${JSON.stringify(manifest)}\n`);
}
console.log(`Combat effects: ${manifest.coverage.authentic}/${manifest.coverage.requested} source mappings; ${manifest.coverage.fallback} intentional fallbacks.`);
if (!options.audit) console.log(`Wrote ${output}`);
}