563 lines
25 KiB
JavaScript
563 lines
25 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";
|
|
|
|
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 };
|
|
}
|
|
|
|
function decodeM2TrackHeader(buffer, offset) {
|
|
if (offset < 0 || offset + 20 > buffer.length) return null;
|
|
return {
|
|
interpolation: buffer.readUInt16LE(offset),
|
|
globalSequence: buffer.readInt16LE(offset + 2),
|
|
timeCount: buffer.readUInt32LE(offset + 4),
|
|
timeOffset: buffer.readUInt32LE(offset + 8),
|
|
keyCount: buffer.readUInt32LE(offset + 12),
|
|
keyOffset: buffer.readUInt32LE(offset + 16),
|
|
};
|
|
}
|
|
|
|
/** Decode the stable WotLK 3.x particle/ribbon fields used by the browser fallback converter. */
|
|
export function decodeWotlkM2Effects(buffer, name = "M2") {
|
|
if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer);
|
|
const magic = buffer.toString("ascii", 0, 4);
|
|
if ((magic !== "MD20" && magic !== "MD21") || buffer.length < 304) {
|
|
throw new Error(`${name} is not a supported WotLK M2.`);
|
|
}
|
|
const ribbonCount = buffer.readUInt32LE(288);
|
|
const ribbonOffset = buffer.readUInt32LE(292);
|
|
const particleCount = buffer.readUInt32LE(296);
|
|
const particleOffset = buffer.readUInt32LE(300);
|
|
const particleRecordSize = 476;
|
|
const ribbonRecordSize = 176;
|
|
if (particleCount && particleOffset + particleCount * particleRecordSize > buffer.length) {
|
|
throw new Error(`${name} has truncated particle records.`);
|
|
}
|
|
if (ribbonCount && ribbonOffset + ribbonCount * ribbonRecordSize > buffer.length) {
|
|
throw new Error(`${name} has truncated ribbon records.`);
|
|
}
|
|
const trackOffsets = {
|
|
emissionSpeed: 56,
|
|
speedVariation: 76,
|
|
verticalRange: 96,
|
|
horizontalRange: 116,
|
|
gravity: 136,
|
|
lifespan: 156,
|
|
emissionRate: 180,
|
|
emissionAreaLength: 200,
|
|
emissionAreaWidth: 220,
|
|
zSource: 240,
|
|
color: 260,
|
|
alpha: 280,
|
|
scale: 300,
|
|
};
|
|
const particles = Array.from({ length: particleCount }, (_, index) => {
|
|
const offset = particleOffset + index * particleRecordSize;
|
|
return {
|
|
id: buffer.readUInt32LE(offset),
|
|
flags: buffer.readUInt32LE(offset + 4),
|
|
position: [buffer.readFloatLE(offset + 8), buffer.readFloatLE(offset + 12), buffer.readFloatLE(offset + 16)],
|
|
parentBone: buffer.readUInt16LE(offset + 20),
|
|
textureIndex: buffer.readUInt16LE(offset + 22),
|
|
blendMode: buffer.readUInt8(offset + 40),
|
|
emitterType: buffer.readUInt8(offset + 41),
|
|
particleType: buffer.readUInt8(offset + 44),
|
|
headOrTail: buffer.readUInt8(offset + 45),
|
|
textureRows: buffer.readUInt16LE(offset + 48),
|
|
textureColumns: buffer.readUInt16LE(offset + 50),
|
|
tracks: Object.fromEntries(Object.entries(trackOffsets)
|
|
.map(([track, relativeOffset]) => [track, decodeM2TrackHeader(buffer, offset + relativeOffset)])),
|
|
};
|
|
});
|
|
const ribbons = Array.from({ length: ribbonCount }, (_, index) => {
|
|
const offset = ribbonOffset + index * ribbonRecordSize;
|
|
return {
|
|
id: buffer.readUInt32LE(offset),
|
|
parentBone: buffer.readUInt32LE(offset + 4),
|
|
position: [buffer.readFloatLE(offset + 8), buffer.readFloatLE(offset + 12), buffer.readFloatLE(offset + 16)],
|
|
textureCount: buffer.readUInt32LE(offset + 20),
|
|
textureOffset: buffer.readUInt32LE(offset + 24),
|
|
materialCount: buffer.readUInt32LE(offset + 28),
|
|
materialOffset: buffer.readUInt32LE(offset + 32),
|
|
colorTrack: decodeM2TrackHeader(buffer, offset + 36),
|
|
};
|
|
});
|
|
return { magic, 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);
|
|
}
|
|
}
|
|
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, (row) => ({
|
|
effects: Array.from({ length: 13 }, (_, offset) => row.uint(3 + offset)).filter(Boolean),
|
|
soundId: row.uint(16),
|
|
}));
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function buildRunewakerEntries(spellIds, imageBySpellId, dataRoot) {
|
|
const magicCollect = fs.readFileSync(path.join(dataRoot, "magiccollectobject.db"));
|
|
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))];
|
|
const images = parseRunewakerImageRecords(fs.readFileSync(path.join(dataRoot, "imageobject.db")), requestedImages);
|
|
const entries = [];
|
|
for (const spellId of spellIds) {
|
|
const imageId = imageBySpellId.get(spellId);
|
|
if (!imageId) continue;
|
|
const paths = images.get(imageId) ?? [];
|
|
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 phase = /(?:^|_)t(?:_|\.)|explode|impact|hit/.test(lower)
|
|
? "impact"
|
|
: /(?:^|_)f(?:_|\.)|fly|missile/.test(lower)
|
|
? "release"
|
|
: /buff|aura|extend/.test(lower)
|
|
? "aura-start"
|
|
: "cast-start";
|
|
(phases[phase] ??= []).push({ model: sourcePath });
|
|
}
|
|
if (!Object.keys(phases).length && !sounds.length) continue;
|
|
entries.push({
|
|
key: `runewaker:${spellId}`,
|
|
source: "runewaker",
|
|
spellId,
|
|
imageId,
|
|
phases,
|
|
...(sounds.length ? { sounds } : {}),
|
|
});
|
|
}
|
|
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 = [
|
|
"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 = 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("/", "\\");
|
|
for (const { archive } of archives) {
|
|
try {
|
|
buffer = archive.extractFile(archiveName);
|
|
if (buffer?.length) break;
|
|
} catch {
|
|
// The source archive stack is sparse by design; continue to the base archives.
|
|
}
|
|
}
|
|
}
|
|
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,
|
|
};
|
|
}
|
|
|
|
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 === "--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 = 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}`);
|
|
}
|