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;