310 lines
12 KiB
JavaScript
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;
|