1035 lines
48 KiB
JavaScript
1035 lines
48 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));
|
|
}
|
|
// Penance's player spell delegates visible healing and damage bolts to
|
|
// child spells that are absent from the learnable progression catalog.
|
|
add("wow335", 47750);
|
|
add("wow335", 47666);
|
|
|
|
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));
|
|
}
|
|
// Creature 19668 uses display 19110: the FleshBeast model with its black skin.
|
|
// The summon is a persistent actor, so retain its model beside the cast kit.
|
|
if (source === "wow335" && spellId === 34433) {
|
|
addAssets("summon", [{ model: "Creature/FleshBeast/FleshBeast.mdx" }]);
|
|
}
|
|
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()]
|
|
.map((sound) => {
|
|
if (source !== "wow335" || ![47540, 47666, 47750].includes(spellId)) return sound;
|
|
if (spellId === 47540) return { ...sound, phase: "cast-start" };
|
|
const phase = /ShadowCast|Pennance_MissileLoop/i.test(sound.sourcePath) ? "tick" : "impact";
|
|
return { ...sound, phase };
|
|
});
|
|
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;
|
|
}
|
|
|
|
// Keep these source links as regression guards for the two hand-reviewed spells.
|
|
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 runewakerTargetModels(magicCollect, magicObject, imageObject, spellIds) {
|
|
const wanted = new Set(spellIds);
|
|
const componentsBySpell = new Map();
|
|
const componentIds = new Set();
|
|
for (let offset = 140; offset + 1032 <= magicCollect.length; offset += 1032) {
|
|
const spellId = magicCollect.readInt32LE(offset);
|
|
if (!wanted.has(spellId)) continue;
|
|
const components = new Set();
|
|
for (let field = 0x180; field <= 0x2c0; field += 4) {
|
|
const componentId = magicCollect.readInt32LE(offset + field);
|
|
if (componentId > 0) components.add(componentId);
|
|
}
|
|
componentsBySpell.set(spellId, components);
|
|
for (const componentId of components) componentIds.add(componentId);
|
|
}
|
|
const componentImages = new Map();
|
|
for (let offset = 140; offset + 992 <= magicObject.length; offset += 992) {
|
|
const componentId = magicObject.readInt32LE(offset);
|
|
if (componentIds.has(componentId)) componentImages.set(componentId, magicObject.readInt32LE(offset + 0x20));
|
|
}
|
|
const imageIds = new Set([...componentImages.values()].filter((id) => id > 0));
|
|
const componentImageFields = new Map();
|
|
for (let offset = 140; offset + 3712 <= imageObject.length; offset += 3712) {
|
|
const imageId = imageObject.readInt32LE(offset);
|
|
if (!imageIds.has(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),
|
|
});
|
|
}
|
|
const result = new Map();
|
|
for (const [spellId, components] of componentsBySpell) {
|
|
const effects = [];
|
|
const seen = new Set();
|
|
for (const componentId of components) {
|
|
const imageId = componentImages.get(componentId);
|
|
const fields = componentImageFields.get(imageId);
|
|
const model = fields?.extendAct;
|
|
if (!model || !/^model\/fx\/[\w./ -]+\.(?:ros|ras)$/.test(model) || model.includes("..")) continue;
|
|
const key = `${model}:${fields.extendPoint}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
effects.push({ spellId, componentId, imageId, model, point: fields.extendPoint });
|
|
}
|
|
if (effects.length) result.set(spellId, effects);
|
|
}
|
|
for (const expected of verifiedRuneWakerTargetEffects) {
|
|
if (!wanted.has(expected.spellId)) continue;
|
|
if (!result.get(expected.spellId)?.some((effect) => Object.entries(expected).every(
|
|
([key, value]) => effect[key] === value,
|
|
))) throw new Error(`RuneWaker target media source link changed for spell ${expected.spellId}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function attachPackagedRuneWakerMedia(entries, resourceRoot) {
|
|
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.graphUrl)
|
|
.map((result) => [result.model.toLowerCase(), result]));
|
|
const overrides = new Map(verifiedRuneWakerParticlePhases.map((effect) => (
|
|
[`${effect.spellId}:${effect.phase}:${effect.model}`, effect]
|
|
)));
|
|
const graphs = new Map();
|
|
for (const entry of entries) for (const [phase, assets] of Object.entries(entry.phases ?? {})) {
|
|
for (const asset of assets) {
|
|
const model = asset.model.toLowerCase();
|
|
const result = decoded.get(model);
|
|
if (!result) continue;
|
|
let graph = graphs.get(model);
|
|
if (!graph) {
|
|
const graphPath = path.join(projectRoot, "public", result.graphUrl.slice(1));
|
|
if (!fs.existsSync(graphPath)) continue;
|
|
graph = JSON.parse(fs.readFileSync(graphPath, "utf8"));
|
|
if (graph.sourceModel !== model) continue;
|
|
graphs.set(model, graph);
|
|
}
|
|
const override = overrides.get(`${entry.spellId}:${phase}:${model}`);
|
|
if (result.rendererReady && graph.rendererReady || override?.partialVisual
|
|
&& result.partialRendererReady && graph.partialRendererReady) {
|
|
asset.effectKind = "runewaker-particles";
|
|
asset.effectGraphUrl = result.graphUrl;
|
|
if (override?.partialVisual) asset.visualCompleteness = "partial";
|
|
if (override?.scale) asset.scale = override.scale;
|
|
}
|
|
// The client starts the appear motion before idle, while target hits use
|
|
// explode. Only zero-offset direct audio callbacks can play on a combat
|
|
// phase boundary with the current manifest audio schema.
|
|
const preferredMotion = phase === "impact" ? "explode" : "idle";
|
|
const selectedMotion = graph.motions?.find((motion) => motion.name === preferredMotion)
|
|
?? graph.motions?.find((motion) => motion.name === "appear")
|
|
?? graph.motions?.[0];
|
|
const eventMotions = new Set(phase === "impact" && graph.motions?.some((motion) => motion.name === "explode")
|
|
? ["explode"] : ["appear", "idle", selectedMotion?.name]);
|
|
const eventSounds = (graph.motions ?? []).filter((motion) => eventMotions.has(motion.name))
|
|
.flatMap((motion) => (motion.controllers ?? []).filter((controller) => (
|
|
controller.sourceType === "CRuController_EventTrigger"
|
|
&& [41, 43].includes(controller.eventID)
|
|
&& controller.startTime === 0
|
|
&& [0, 1].includes(controller.eventParameter0)
|
|
)).map((controller) => ({
|
|
sourcePath: controller.eventString?.replaceAll("\\", "/").replace(/^\/+/, "").toLowerCase(),
|
|
loopDuringCast: controller.eventParameter0 === 1 && phase === "cast-start",
|
|
}))).filter((sound) => sound.sourcePath
|
|
&& /^sound\/fx\/[\w./ -]+\.(?:mp3|wav|ogg)$/.test(sound.sourcePath)
|
|
&& !sound.sourcePath.includes("..")
|
|
&& fs.existsSync(path.join(resourceRoot, ...sound.sourcePath.split("/"))));
|
|
if (eventSounds.length) {
|
|
const mirrored = runewakerSoundPathsForModel(model, resourceRoot);
|
|
entry.sounds = (entry.sounds ?? []).filter((sound) => sound.phase !== phase
|
|
|| !mirrored.includes(sound.sourcePath));
|
|
for (const sound of eventSounds) {
|
|
if (entry.sounds?.some((candidate) => candidate.phase === phase
|
|
&& candidate.sourcePath.toLowerCase() === sound.sourcePath)) continue;
|
|
(entry.sounds ??= []).push({ ...sound, phase, weight: 1, volume: 1 });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 = runewakerTargetModels(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()].flatMap((effects) => effects.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 targetModels = (targetEffects.get(spellId) ?? []).map((effect) => {
|
|
const model = (images.get(effect.imageId) ?? []).find((candidate) => (
|
|
candidate.toLowerCase() === effect.model
|
|
));
|
|
if (!model) throw new Error(`RuneWaker target ImageObject changed for spell ${spellId}`);
|
|
return model;
|
|
});
|
|
const paths = [...(images.get(imageId) ?? []), ...targetModels];
|
|
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);
|
|
attachPackagedRuneWakerMedia(entries, resourceRoot);
|
|
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}`);
|
|
}
|