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,159 @@
|
||||
#!/usr/bin/env node
|
||||
// Read campaign metadata without booting the full combat runtime.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { decodeBlp2, encodePng } from "../lib/blp.mjs";
|
||||
import { stitchMap } from "../lib/wow-map-image.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const require = createRequire(import.meta.url);
|
||||
const compressedSource = fs.readFileSync(path.join(root, "src/game/generated/dungeonCampaignCatalog.compressed.ts"), "utf8");
|
||||
const compressed = compressedSource.match(/export default "([A-Za-z0-9+/=]+)"/)?.[1];
|
||||
if (!compressed) throw new Error("Missing compressed campaign metadata");
|
||||
const campaign = JSON.parse(gunzipSync(Buffer.from(compressed, "base64")).toString("utf8"));
|
||||
const authoredWailing = fs.readFileSync(path.join(root, "src/game/dungeons/wailingCaverns.ts"), "utf8");
|
||||
if (!authoredWailing.includes('fromWow: "three(x,y,z)=(-wow.x,wow.z,wow.y)"')) throw new Error("Wailing Caverns basis changed; review the map importer");
|
||||
const definitions = campaign.definitions.map((definition) => definition.id === "wailing-caverns"
|
||||
? { ...definition, assets: { visual: [{}] }, provenance: { ...definition.provenance, coordinateTransform: "three(x,y,z)=(-wow.x,wow.z,wow.y)" } }
|
||||
: definition);
|
||||
const { MpqArchive } = require("stormlib-js");
|
||||
const source = path.resolve(process.argv[2] ?? path.join(root, "../downloads/patch-enUS-M.MPQ"));
|
||||
const client = path.resolve(process.env.WOW_CLIENT_ROOT ?? path.join(root, "../wow335a"));
|
||||
const work = path.resolve(process.env.WOW_DUNGEON_WORK_ROOT ?? path.join(root, "../HealerMan-Storage/pipeline-work/dungeons"));
|
||||
const archivePaths = [source, ...[
|
||||
"patch-enUS-3.MPQ", "patch-enUS-2.MPQ", "patch-enUS.MPQ",
|
||||
"lichking-locale-enUS.MPQ", "expansion-locale-enUS.MPQ", "locale-enUS.MPQ",
|
||||
].map((name) => path.join(client, "Data/enUS", name))];
|
||||
const archives = archivePaths.filter(fs.existsSync).map((file) => ({ file, archive: MpqArchive.open(file, { noListfile: true, noAttributes: true }) }));
|
||||
const sourcesUsed = new Set();
|
||||
const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex");
|
||||
function extract(name) {
|
||||
for (const entry of archives) {
|
||||
if (!entry.archive.hasFile(name)) continue;
|
||||
sourcesUsed.add(entry.file);
|
||||
return entry.archive.extractFile(name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function dbc(name, fields) {
|
||||
const b = extract(`DBFilesClient/${name}.dbc`);
|
||||
if (!b || b.toString("ascii", 0, 4) !== "WDBC" || b.readUInt32LE(8) !== fields) throw new Error(`Invalid ${name} DBC`);
|
||||
const count = b.readUInt32LE(4), size = b.readUInt32LE(12), stringStart = 20 + count * size;
|
||||
if (size !== fields * 4 || stringStart + b.readUInt32LE(16) > b.length) throw new Error(`Truncated ${name} DBC`);
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const start = 20 + i * size;
|
||||
return {
|
||||
int: (f) => b.readInt32LE(start + f * 4),
|
||||
float: (f) => b.readFloatLE(start + f * 4),
|
||||
string: (f) => {
|
||||
const offset = b.readUInt32LE(start + f * 4);
|
||||
const end = b.indexOf(0, stringStart + offset);
|
||||
return b.toString("utf8", stringStart + offset, end);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
function readJson(file) { return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null; }
|
||||
const add = (a, b) => a.map((v, i) => v + b[i]);
|
||||
const subtract = (a, b) => a.map((v, i) => v - b[i]);
|
||||
function rotate(point, degrees) {
|
||||
const angle = degrees * Math.PI / 180, c = Math.cos(angle), s = Math.sin(angle);
|
||||
return [point[0] * c - point[2] * s, point[1], point[0] * s + point[2] * c];
|
||||
}
|
||||
|
||||
/** Inverse of the reviewed campaign mapper, including WMO yaw and anchor offsets. */
|
||||
function runtimeToWow(definition, recipe, draft) {
|
||||
const authoredWailing = definition.id === "wailing-caverns";
|
||||
const adt = definition.environmentMode === "adt-hybrid";
|
||||
const flip = recipe.globalWmoSourceBasis === "flip-x";
|
||||
const adtBasis = recipe.adtSourceBasis ?? (recipe.clientBuild === "3.3.5a-Epoch" ? "z-y-negative-x" : "negative-z-y-negative-x");
|
||||
const basisDraft = (p) => adt ? [adtBasis === "z-y-negative-x" ? p[2] : -p[2], p[1], -p[0]] : [flip ? -p[0] : p[0], p[1], p[2]];
|
||||
const basisWow = (p) => authoredWailing ? [-p[0], p[2], p[1]] : basisDraft([-p[0], p[2], p[1]]);
|
||||
const alignment = definition.provenance.coordinateAlignment ?? "source";
|
||||
const yaw = !authoredWailing && !adt && alignment === "source-transform" ? recipe.coordinateTransform?.yawDegrees ?? 0 : 0;
|
||||
let offset = [0, 0, 0];
|
||||
if (!authoredWailing && alignment === "source-transform" && !adt) {
|
||||
if (!recipe.coordinateTransform) throw new Error(`${definition.id}: missing source transform`);
|
||||
offset = rotate(basisDraft(recipe.coordinateTransform.origin).map((v) => -v), yaw);
|
||||
} else if (!authoredWailing && alignment === "player-anchor") {
|
||||
if (!draft?.entrance) throw new Error(`${definition.id}: missing draft entrance for anchor alignment`);
|
||||
offset = subtract(definition.entrance.footPosition, basisDraft(draft.entrance));
|
||||
}
|
||||
const axes = [[1, 0, 0], [0, 1, 0], [0, 0, 1]].map((p) => rotate(basisWow(p), yaw));
|
||||
const inverse = axes.map((axis) => [...axis, -axis.reduce((sum, value, i) => sum + value * offset[i], 0)]);
|
||||
// Verify against independent imported spawn positions, not the dungeon bounds.
|
||||
const pairs = (draft?.spawns ?? []).flatMap((spawn) => {
|
||||
const runtime = definition.staticSpawns.find((candidate) => candidate.id === spawn.id);
|
||||
return runtime ? [{ runtime: runtime.position, wow: [-spawn.position[0], spawn.position[2], spawn.position[1]] }] : [];
|
||||
});
|
||||
const checkedSpawns = pairs.length;
|
||||
// Event-driven arenas can have no static actors; verify their source entrance instead.
|
||||
const checkedEntrances = !pairs.length && draft?.entrance ? 1 : 0;
|
||||
if (checkedEntrances) pairs.push({ runtime: definition.entrance.footPosition, wow: [-draft.entrance[0], draft.entrance[2], draft.entrance[1]] });
|
||||
if (!pairs.length) throw new Error(`${definition.id}: no source positions available to verify alignment`);
|
||||
const error = Math.max(0, ...pairs.map(({ runtime, wow }) => Math.hypot(...inverse.map((axis, i) => axis[3] + axis.slice(0, 3).reduce((sum, v, j) => sum + v * runtime[j], 0) - wow[i]))));
|
||||
if (error > (authoredWailing ? 0.25 : 0.15)) throw new Error(`${definition.id}: map alignment failed (${error.toFixed(3)} yards)`);
|
||||
return { matrix: inverse.flat().map((v) => Number(v.toFixed(10))), checkedSpawns, checkedEntrances, maxError: error };
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const areas = dbc("WorldMapArea", 11).map((r) => ({
|
||||
id: r.int(0), mapId: r.int(1), name: r.string(3),
|
||||
bounds: [r.float(7), r.float(6), r.float(5), r.float(4)],
|
||||
}));
|
||||
const floors = dbc("DungeonMap", 8).map((r) => ({
|
||||
dbcId: r.int(0), mapId: r.int(1), level: r.int(2),
|
||||
// DungeonMap: MinX, MaxX, MinY, MaxY (world X is north, Y is west).
|
||||
bounds: [r.float(5), r.float(6), r.float(3), r.float(4)],
|
||||
}));
|
||||
const strings = extract("Interface/FrameXML/GlobalStrings.lua")?.toString("utf8") ?? "";
|
||||
const names = Object.fromEntries([...strings.matchAll(/DUNGEON_FLOOR_(\w+)\s*=\s*"([^"]+)"/g)].map((m) => [m[1].toLowerCase(), m[2]]));
|
||||
const catalog = { schemaVersion: 1, width: 1002, height: 668, dungeons: {} };
|
||||
const report = { source: path.basename(source), sourceSha256: sha256(fs.readFileSync(source)), imported: [], skipped: [], coordinateSources: ["WorldMapArea.dbc", "DungeonMap.dbc"], layoutSource: "https://github.com/wowgaming/3.3.5-interface-files/blob/main/WorldMapFrame.xml" };
|
||||
for (const definition of definitions.filter((d) => d.expansion !== "runewaker")) {
|
||||
console.log(`Importing ${definition.id}`);
|
||||
const area = areas.find((a) => a.mapId === definition.mapId);
|
||||
if (!area) { report.skipped.push({ id: definition.id, reason: "No matching WorldMapArea record" }); continue; }
|
||||
const recipe = readJson(path.join(root, "dungeon-pipeline/recipes", `${definition.id}.json`)) ?? {};
|
||||
const draft = readJson(path.join(work, definition.id, "runtime-draft.json"));
|
||||
const candidates = floors.filter((floor) => floor.mapId === definition.mapId)
|
||||
.filter((floor, index, entries) => entries.findIndex((entry) => entry.level === floor.level) === index);
|
||||
if (area.bounds[1] > area.bounds[0] && area.bounds[3] > area.bounds[2]) candidates.unshift({ level: 0, bounds: area.bounds });
|
||||
const importedFloors = [];
|
||||
for (const floor of candidates) {
|
||||
if (!floor.bounds.every(Number.isFinite) || floor.bounds[1] <= floor.bounds[0] || floor.bounds[3] <= floor.bounds[2]) continue;
|
||||
const stem = floor.level ? `${area.name}${floor.level}_` : area.name;
|
||||
const tilePaths = Array.from({ length: 12 }, (_, i) => `Interface/WorldMap/${area.name}/${stem}${i + 1}.blp`);
|
||||
const buffers = tilePaths.map(extract);
|
||||
if (buffers.some((b) => !b)) continue;
|
||||
const png = encodePng(stitchMap(buffers.map((b, i) => decodeBlp2(b, tilePaths[i]))));
|
||||
const logicalPath = `/assets/game/dungeons/${definition.id}/maps/floor-${floor.level}.png`;
|
||||
const output = path.join(root, "public", logicalPath);
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
fs.writeFileSync(output, png);
|
||||
importedFloors.push({ level: floor.level, name: names[`${area.name}${floor.level}`.toLowerCase()] ?? (floor.level ? `Floor ${floor.level}` : "Overview"), bounds: floor.bounds, image: logicalPath, checksum: sha256(png) });
|
||||
}
|
||||
if (!importedFloors.length) { report.skipped.push({ id: definition.id, reason: "No complete twelve-tile map in supplied/client archives" }); continue; }
|
||||
let alignment;
|
||||
try { alignment = runtimeToWow(definition, recipe, draft); }
|
||||
catch (error) { report.skipped.push({ id: definition.id, reason: error.message }); continue; }
|
||||
catalog.dungeons[definition.id] = {
|
||||
mapId: definition.mapId, coordinateTransform: definition.provenance.coordinateTransform,
|
||||
visualChecksum: definition.assets.visual[0]?.checksum, runtimeToWow: alignment.matrix,
|
||||
floors: importedFloors,
|
||||
};
|
||||
report.imported.push({ id: definition.id, floors: importedFloors.length, checkedSpawns: alignment.checkedSpawns, checkedEntrances: alignment.checkedEntrances, maxAlignmentError: alignment.maxError });
|
||||
}
|
||||
report.archives = [...sourcesUsed].map((file) => path.basename(file));
|
||||
fs.writeFileSync(path.join(root, "src/game/generated/wowDungeonMaps.generated.json"), `${JSON.stringify(catalog, null, 2)}\n`);
|
||||
fs.writeFileSync(path.join(root, "dungeon-pipeline/WOW_DUNGEON_MAP_REPORT.json"), `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(`Imported ${report.imported.length} dungeon maps / ${report.imported.reduce((sum, entry) => sum + entry.floors, 0)} floors; skipped ${report.skipped.length}`);
|
||||
for (const skip of report.skipped) console.log(` ${skip.id}: ${skip.reason}`);
|
||||
} finally {
|
||||
for (const { archive } of archives) archive.close();
|
||||
}
|
||||
@@ -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