Files
healer-man/scripts/export-spell-icons.mjs
2026-08-14 15:56:39 -04:00

310 lines
12 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import zlib from "node:zlib";
const projectRoot = path.resolve(import.meta.dirname, "..");
const catalogPaths = [
path.join(projectRoot, "src", "game", "abilityCatalog.ts"),
path.join(projectRoot, "src", "game", "coaAbilityCatalog.ts"),
];
const characterCatalogPath = path.join(projectRoot, "src", "app", "characterCatalog.ts");
const coaGeneratedPath = path.join(projectRoot, "src", "game", "coaLiveData.generated.json");
const wow335GeneratedPath = path.join(projectRoot, "src", "game", "wow335AbilityData.generated.json");
const manastormAffixesPath = path.join(projectRoot, "src", "game", "generated", "manastormAffixes.json");
const manastormRuntimePath = path.join(projectRoot, "src", "game", "generated", "manastormRuntimeCatalog.json");
const defaultSource = path.resolve(projectRoot, "..", "LadiksMPQEditor", "patch-I", "Interface", "Icons");
const sourceDirectory = path.resolve(process.argv[2] ?? defaultSource);
const outputDirectory = path.join(projectRoot, "public", "assets", "ui", "spells");
const catalogSources = catalogPaths.map((catalogPath) => fs.readFileSync(catalogPath, "utf8"));
const characterCatalogSource = fs.readFileSync(characterCatalogPath, "utf8");
const catalogIconBasenames = catalogSources.flatMap((catalogSource, index) => [
...[...catalogSource.matchAll(/spellIcon\("([^"]+)"\)/g)].map((match) => match[1]),
...(index === 1
? [...catalogSource.matchAll(/\bicon:\s*"([^"]+)"/g)].map((match) => match[1])
: []),
]);
const coaGenerated = fs.existsSync(coaGeneratedPath)
? JSON.parse(fs.readFileSync(coaGeneratedPath, "utf8"))
: null;
const coaIconBasenames = coaGenerated
? [
...coaGenerated.entries.map((entry) => entry.iconBasename),
...coaGenerated.progressionChains.map((chain) => chain.iconBasename),
]
: [];
const wow335Generated = fs.existsSync(wow335GeneratedPath)
? JSON.parse(fs.readFileSync(wow335GeneratedPath, "utf8"))
: null;
const wow335IconBasenames = wow335Generated
? Object.values(wow335Generated.classes).flat().map((chain) => chain.iconBasename)
: [];
const classIconBasenames = [...characterCatalogSource.matchAll(/"(classicon128_[^"]+)"/g)]
.map((match) => match[1]);
const manastormIconBasenames = fs.existsSync(manastormAffixesPath)
? JSON.parse(fs.readFileSync(manastormAffixesPath, "utf8")).affixes
.map((affix) => affix.iconReference)
: [];
const manastormMessageIconBasenames = fs.existsSync(manastormRuntimePath)
? (JSON.parse(fs.readFileSync(manastormRuntimePath, "utf8")).modes ?? [])
.flatMap((mode) => mode.messages ?? [])
.map((message) => message.iconReference)
: [];
const iconBasenames = [
...catalogIconBasenames,
...coaIconBasenames,
...wow335IconBasenames,
...classIconBasenames,
...manastormIconBasenames,
...manastormMessageIconBasenames,
]
.map((basename) => basename.toLowerCase())
.filter(Boolean)
.filter((basename, index, all) => all.indexOf(basename) === index)
.sort();
if (!iconBasenames.length) throw new Error("No runtime spell icons were found in the ability catalogs");
function expand565(value) {
const red = (value >>> 11) & 0x1f;
const green = (value >>> 5) & 0x3f;
const blue = value & 0x1f;
return [
Math.round(red * 255 / 31),
Math.round(green * 255 / 63),
Math.round(blue * 255 / 31),
255,
];
}
function mix(first, second, firstWeight, secondWeight, divisor) {
return [
Math.round((first[0] * firstWeight + second[0] * secondWeight) / divisor),
Math.round((first[1] * firstWeight + second[1] * secondWeight) / divisor),
Math.round((first[2] * firstWeight + second[2] * secondWeight) / divisor),
255,
];
}
function decodeColorBlock(buffer, offset, forceFourColors) {
const firstValue = buffer.readUInt16LE(offset);
const secondValue = buffer.readUInt16LE(offset + 2);
const first = expand565(firstValue);
const second = expand565(secondValue);
const colors = [first, second];
if (forceFourColors || firstValue > secondValue) {
colors.push(mix(first, second, 2, 1, 3), mix(first, second, 1, 2, 3));
} else {
colors.push(mix(first, second, 1, 1, 2), [0, 0, 0, 0]);
}
return { colors, indices: buffer.readUInt32LE(offset + 4) };
}
function decodeDxt3Alpha(buffer, offset) {
const alpha = new Uint8Array(16);
for (let pixel = 0; pixel < 16; pixel += 1) {
const nibble = (buffer[offset + Math.floor(pixel / 2)] >>> ((pixel % 2) * 4)) & 0xf;
alpha[pixel] = nibble * 17;
}
return alpha;
}
function decodeDxt5Alpha(buffer, offset) {
const first = buffer[offset];
const second = buffer[offset + 1];
const table = new Uint8Array(8);
table[0] = first;
table[1] = second;
if (first > second) {
for (let index = 1; index <= 6; index += 1) {
table[index + 1] = Math.round(((7 - index) * first + index * second) / 7);
}
} else {
for (let index = 1; index <= 4; index += 1) {
table[index + 1] = Math.round(((5 - index) * first + index * second) / 5);
}
table[6] = 0;
table[7] = 255;
}
let bits = 0n;
for (let byte = 0; byte < 6; byte += 1) bits |= BigInt(buffer[offset + 2 + byte]) << BigInt(byte * 8);
const alpha = new Uint8Array(16);
for (let pixel = 0; pixel < 16; pixel += 1) {
alpha[pixel] = table[Number((bits >> BigInt(pixel * 3)) & 7n)];
}
return alpha;
}
function decodePalettedBlp2(buffer, sourcePath, alphaDepth, width, height, mipOffset, mipSize) {
const pixelCount = width * height;
const alphaBytes = alphaDepth === 0
? 0
: alphaDepth === 1
? Math.ceil(pixelCount / 8)
: alphaDepth === 4
? Math.ceil(pixelCount / 2)
: alphaDepth === 8
? pixelCount
: -1;
if (alphaBytes < 0) throw new Error(`${sourcePath} uses unsupported paletted alpha depth ${alphaDepth}`);
if (mipSize < pixelCount + alphaBytes || mipOffset + pixelCount + alphaBytes > buffer.length) {
throw new Error(`${sourcePath} has a truncated paletted base mip`);
}
const paletteOffset = 148;
if (paletteOffset + 256 * 4 > buffer.length) throw new Error(`${sourcePath} has a truncated palette`);
const alphaOffset = mipOffset + pixelCount;
const pixels = Buffer.alloc(pixelCount * 4);
for (let pixel = 0; pixel < pixelCount; pixel += 1) {
const paletteEntry = paletteOffset + buffer[mipOffset + pixel] * 4;
const output = pixel * 4;
pixels[output] = buffer[paletteEntry + 2];
pixels[output + 1] = buffer[paletteEntry + 1];
pixels[output + 2] = buffer[paletteEntry];
pixels[output + 3] = alphaDepth === 0
? 255
: alphaDepth === 1
? ((buffer[alphaOffset + Math.floor(pixel / 8)] >>> (pixel % 8)) & 1) * 255
: alphaDepth === 4
? ((buffer[alphaOffset + Math.floor(pixel / 2)] >>> ((pixel % 2) * 4)) & 0xf) * 17
: buffer[alphaOffset + pixel];
}
return { width, height, pixels };
}
function decodeBlp2(buffer, sourcePath) {
if (buffer.toString("ascii", 0, 4) !== "BLP2") throw new Error(`${sourcePath} is not a BLP2 file`);
const type = buffer.readUInt32LE(4);
const encoding = buffer[8];
const alphaDepth = buffer[9];
const alphaEncoding = buffer[10];
const width = buffer.readUInt32LE(12);
const height = buffer.readUInt32LE(16);
const mipOffset = buffer.readUInt32LE(20);
const mipSize = buffer.readUInt32LE(84);
if (type !== 1) throw new Error(`${sourcePath} uses unsupported BLP2 type ${type}`);
if (!width || !height || !mipOffset || !mipSize) throw new Error(`${sourcePath} has no usable base mip`);
if (encoding === 1) {
return decodePalettedBlp2(buffer, sourcePath, alphaDepth, width, height, mipOffset, mipSize);
}
if (encoding !== 2) throw new Error(`${sourcePath} uses unsupported BLP2 encoding ${encoding}`);
const dxtMode = alphaDepth === 0 || alphaEncoding === 0
? "dxt1"
: alphaEncoding === 1
? "dxt3"
: alphaEncoding === 7
? "dxt5"
: null;
if (!dxtMode) throw new Error(`${sourcePath} uses unsupported alpha encoding ${alphaEncoding}`);
const bytesPerBlock = dxtMode === "dxt1" ? 8 : 16;
const blockColumns = Math.ceil(width / 4);
const blockRows = Math.ceil(height / 4);
if (blockColumns * blockRows * bytesPerBlock > mipSize) throw new Error(`${sourcePath} has a truncated base mip`);
const pixels = Buffer.alloc(width * height * 4);
for (let blockY = 0; blockY < blockRows; blockY += 1) {
for (let blockX = 0; blockX < blockColumns; blockX += 1) {
const blockOffset = mipOffset + (blockY * blockColumns + blockX) * bytesPerBlock;
const alpha = dxtMode === "dxt3"
? decodeDxt3Alpha(buffer, blockOffset)
: dxtMode === "dxt5"
? decodeDxt5Alpha(buffer, blockOffset)
: null;
const colorOffset = blockOffset + (dxtMode === "dxt1" ? 0 : 8);
const { colors, indices } = decodeColorBlock(buffer, colorOffset, dxtMode !== "dxt1");
for (let localY = 0; localY < 4; localY += 1) {
for (let localX = 0; localX < 4; localX += 1) {
const x = blockX * 4 + localX;
const y = blockY * 4 + localY;
if (x >= width || y >= height) continue;
const blockPixel = localY * 4 + localX;
const color = colors[(indices >>> (blockPixel * 2)) & 3];
const output = (y * width + x) * 4;
pixels[output] = color[0];
pixels[output + 1] = color[1];
pixels[output + 2] = color[2];
pixels[output + 3] = alpha?.[blockPixel] ?? color[3];
}
}
}
}
return { width, height, pixels };
}
const crcTable = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
crcTable[index] = value >>> 0;
}
function crc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) {
const typeBuffer = Buffer.from(type, "ascii");
const output = Buffer.alloc(data.length + 12);
output.writeUInt32BE(data.length, 0);
typeBuffer.copy(output, 4);
data.copy(output, 8);
output.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), data.length + 8);
return output;
}
function encodePng({ width, height, pixels }) {
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8;
header[9] = 6;
const scanlines = Buffer.alloc(height * (width * 4 + 1));
for (let y = 0; y < height; y += 1) pixels.copy(scanlines, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
pngChunk("IHDR", header),
pngChunk("IDAT", zlib.deflateSync(scanlines, { level: 9 })),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
fs.mkdirSync(outputDirectory, { recursive: true });
const fallbackSourcePath = path.join(sourceDirectory, "inv_misc_questionmark.blp");
let fallbacks = 0;
const fallbackDetails = [];
for (const basename of iconBasenames) {
const sourcePath = path.join(sourceDirectory, `${basename}.blp`);
const missingSource = !fs.existsSync(sourcePath);
const resolvedSourcePath = missingSource ? fallbackSourcePath : sourcePath;
if (missingSource) {
fallbacks += 1;
fallbackDetails.push({ basename, reason: "missing source icon" });
}
let png;
try {
png = encodePng(decodeBlp2(fs.readFileSync(resolvedSourcePath), resolvedSourcePath));
} catch (error) {
if (resolvedSourcePath === fallbackSourcePath) throw error;
fallbacks += 1;
fallbackDetails.push({ basename, reason: String(error.message ?? error) });
png = encodePng(decodeBlp2(fs.readFileSync(fallbackSourcePath), fallbackSourcePath));
}
fs.writeFileSync(path.join(outputDirectory, `${basename}.png`), png);
}
console.log(JSON.stringify({
exported: iconBasenames.length,
fallbacks,
fallbackDetails,
sourceDirectory,
outputDirectory,
}));
if (fallbacks > 0) process.exitCode = 1;