Release Healer Man 0.1.4
This commit is contained in:
@@ -0,0 +1,562 @@
|
||||
#!/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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user