forked from phenom/healer-man
Release Healer Man 0.1.40
This commit is contained in:
1 parent
ed0d2fea3d
commit
197a64117c
215 files changed
+5131
-181
No files matched your search
@@ -0,0 +1,212 @@
|
||||
import zlib from "node:zlib";
|
||||
|
||||
// BLP2/PNG codec adapted from the established spell-icon export pipeline.
|
||||
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 };
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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)),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** WoW's twelve row-major 256px tiles are cropped to the client map's 1002x668 rectangle. */
|
||||
export function stitchMap(tiles) {
|
||||
if (tiles.length !== 12) throw new Error("A map requires all twelve tiles");
|
||||
const width = 1002, height = 668, pixels = Buffer.alloc(width * height * 4);
|
||||
tiles.forEach((tile, index) => {
|
||||
if (tile.width !== 256 || tile.height !== 256) throw new Error("Unexpected map tile dimensions");
|
||||
const x = (index % 4) * 256, y = Math.floor(index / 4) * 256;
|
||||
const copyWidth = Math.min(256, width - x), copyHeight = Math.min(256, height - y);
|
||||
for (let row = 0; row < copyHeight; row += 1) tile.pixels.copy(pixels, ((y + row) * width + x) * 4, row * 256 * 4, (row * 256 + copyWidth) * 4);
|
||||
});
|
||||
return { width, height, pixels };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { decodeBlp2, encodePng } from "./blp.mjs";
|
||||
import { stitchMap } from "./wow-map-image.mjs";
|
||||
|
||||
test("map tiles are row-major and cropped to the original client's 1002x668 area", () => {
|
||||
const tiles = Array.from({ length: 12 }, (_, i) => ({ width: 256, height: 256, pixels: Buffer.alloc(256 * 256 * 4, i) }));
|
||||
const image = stitchMap(tiles);
|
||||
assert.deepEqual([image.width, image.height], [1002, 668]);
|
||||
const pixel = (x, y) => image.pixels[(y * image.width + x) * 4];
|
||||
assert.equal(pixel(0, 0), 0);
|
||||
assert.equal(pixel(256, 0), 1);
|
||||
assert.equal(pixel(1001, 0), 3);
|
||||
assert.equal(pixel(0, 256), 4);
|
||||
assert.equal(pixel(0, 667), 8);
|
||||
assert.equal(pixel(1001, 667), 11);
|
||||
const png = encodePng(image);
|
||||
assert.equal(png.toString("ascii", 1, 4), "PNG");
|
||||
assert.equal(png.readUInt32BE(16), 1002);
|
||||
assert.equal(png.readUInt32BE(20), 668);
|
||||
});
|
||||
|
||||
test("partial or unexpected-size tile sets cannot become a misleading map", () => {
|
||||
assert.throws(() => stitchMap([]), /twelve/);
|
||||
assert.throws(() => stitchMap(Array.from({ length: 12 }, () => ({ width: 128, height: 256 }))), /dimensions/);
|
||||
});
|
||||
|
||||
test("BLP2 DXT1 map tiles decode RGB565 color correctly", () => {
|
||||
const b = Buffer.alloc(156);
|
||||
b.write("BLP2"); b.writeUInt32LE(1, 4); b[8] = 2;
|
||||
b.writeUInt32LE(4, 12); b.writeUInt32LE(4, 16);
|
||||
b.writeUInt32LE(148, 20); b.writeUInt32LE(8, 84);
|
||||
b.writeUInt16LE(0xf800, 148);
|
||||
const decoded = decodeBlp2(b, "red fixture");
|
||||
assert.deepEqual([...decoded.pixels.subarray(0, 4)], [255, 0, 0, 255]);
|
||||
assert.equal(decoded.pixels.length, 64);
|
||||
});
|
||||
Reference in new issue
Block a user