Update Healer Man server

This commit is contained in:
phenom committed 2026-10-07 13:05:04 -04:00
1 parent 69809f7228
commit ff20ab8779
101 files changed
+16713 -945

No files matched your search

@@ -0,0 +1,265 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
function chunkId(buffer, offset) {
return Buffer.from(buffer.subarray(offset, offset + 4)).reverse().toString("ascii");
}
function readChunkList(buffer, start, end, label) {
const chunks = [];
let offset = start;
while (offset + 8 <= end) {
const id = chunkId(buffer, offset);
const size = buffer.readUInt32LE(offset + 4);
const dataOffset = offset + 8;
const chunkEnd = dataOffset + size;
if (chunkEnd > end) throw new Error(`${label}: ${id} at ${offset} extends beyond its container.`);
chunks.push({ id, offset, dataOffset, size, end: chunkEnd });
offset = chunkEnd;
}
if (offset !== end) throw new Error(`${label}: ${end - offset} trailing bytes cannot form a chunk header.`);
return chunks;
}
function readSubchunk(buffer, mcnk, relativeOffset, label) {
if (!relativeOffset) return null;
const offset = mcnk.offset + relativeOffset;
if (offset + 8 > buffer.length) throw new Error(`${label}: subchunk offset ${relativeOffset} leaves the ADT.`);
const id = chunkId(buffer, offset);
const size = buffer.readUInt32LE(offset + 4);
const dataOffset = offset + 8;
const end = dataOffset + size;
if (end > buffer.length) throw new Error(`${label}: ${id} subchunk leaves the ADT.`);
return { id, offset, dataOffset, size, end, withinMcnk: end <= mcnk.end };
}
function textureNames(buffer, mtex) {
if (!mtex) return [];
const value = buffer.toString("utf8", mtex.dataOffset, mtex.end);
return value.split("\0").filter(Boolean).map((name) => name.replaceAll("/", "\\"));
}
function hash(buffer) {
return createHash("sha256").update(buffer).digest("hex");
}
export function parseWowAdtTerrain(buffer, { label = "ADT" } = {}) {
if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer);
const chunks = readChunkList(buffer, 0, buffer.length, label);
if (chunks[0]?.id !== "MVER") throw new Error(`${label}: missing MVER first chunk.`);
const mtex = chunks.find((chunk) => chunk.id === "MTEX");
const textures = textureNames(buffer, mtex);
const mcnks = chunks.filter((chunk) => chunk.id === "MCNK");
const terrainChunks = mcnks.map((mcnk, ordinal) => {
if (mcnk.size < 128) throw new Error(`${label}: MCNK ${ordinal} is shorter than its 128-byte header.`);
const data = mcnk.dataOffset;
const flags = buffer.readUInt32LE(data);
const indexX = buffer.readUInt32LE(data + 4);
const indexY = buffer.readUInt32LE(data + 8);
const layerCount = buffer.readUInt32LE(data + 12);
const height = readSubchunk(buffer, mcnk, buffer.readUInt32LE(data + 20), `${label} MCNK ${ordinal} MCVT`);
const layersChunk = readSubchunk(buffer, mcnk, buffer.readUInt32LE(data + 28), `${label} MCNK ${ordinal} MCLY`);
const alphaChunkHeader = readSubchunk(buffer, mcnk, buffer.readUInt32LE(data + 36), `${label} MCNK ${ordinal} MCAL`);
const alphaSizeHeader = buffer.readUInt32LE(data + 40);
// A subset of MVER 18 ADTs has an MCAL FourCC followed by a zero chunk
// size, while the enclosing MCNK header carries the complete MCAL size.
// The bytes after the eight-byte subchunk header are still the source map.
const alphaChunk = alphaChunkHeader?.id === "MCAL" && alphaChunkHeader.size === 0 && alphaSizeHeader > 8
? {
...alphaChunkHeader,
size: alphaSizeHeader - 8,
end: alphaChunkHeader.dataOffset + alphaSizeHeader - 8,
withinMcnk: alphaChunkHeader.dataOffset + alphaSizeHeader - 8 <= mcnk.end,
sizeSource: "mcnk-header",
}
: alphaChunkHeader && { ...alphaChunkHeader, sizeSource: "mcal-subchunk-header" };
const worldHeight = buffer.readFloatLE(data + 104);
const worldX = buffer.readFloatLE(data + 108);
const worldY = buffer.readFloatLE(data + 112);
const holeMask = buffer.readUInt16LE(data + 60);
const lowResolutionTextureMap = Array.from({ length: 8 }, (_, index) => buffer.readUInt16LE(data + 64 + index * 2));
if (layerCount && (!layersChunk || layersChunk.id !== "MCLY" || layersChunk.size < layerCount * 16)) {
throw new Error(`${label}: MCNK ${ordinal} layer header does not match its ${layerCount} layers.`);
}
if (height && height.id !== "MCVT") throw new Error(`${label}: MCNK ${ordinal} height offset points to ${height.id}.`);
if (alphaChunk && alphaChunk.id !== "MCAL") throw new Error(`${label}: MCNK ${ordinal} alpha offset points to ${alphaChunk.id}.`);
const layers = Array.from({ length: layerCount }, (_, index) => {
const offset = layersChunk.dataOffset + index * 16;
const textureIndex = buffer.readUInt32LE(offset);
const layerFlags = buffer.readUInt32LE(offset + 4);
const alphaOffset = buffer.readUInt32LE(offset + 8);
const effectId = buffer.readUInt32LE(offset + 12);
const usesAlphaMap = (layerFlags & 0x100) !== 0;
const alphaMapCompressed = (layerFlags & 0x200) !== 0;
if (textureIndex >= textures.length) {
throw new Error(`${label}: MCNK ${ordinal} layer ${index} references MTEX index ${textureIndex}/${textures.length}.`);
}
return {
index,
recordOffset: offset,
textureIndex,
texture: textures[textureIndex],
flags: layerFlags,
usesAlphaMap,
alphaMapCompressed,
alphaMapAvailable: false,
alphaMapEncoding: null,
alphaMapBytes: 0,
alphaMapDataOffset: null,
alphaOffset: usesAlphaMap ? alphaOffset : null,
effectId,
};
});
for (const layer of layers) {
if (!layer.usesAlphaMap || !alphaChunk) continue;
const laterAlphaLayer = layers.find((candidate) => candidate.usesAlphaMap && candidate.index > layer.index);
const alphaBytes = laterAlphaLayer
? laterAlphaLayer.alphaOffset - layer.alphaOffset
: alphaChunk.size - layer.alphaOffset;
const alphaMapDataOffset = alphaChunk.dataOffset + layer.alphaOffset;
const encoding = layer.alphaMapCompressed
? "rle-8"
: alphaBytes === 4096
? "uncompressed-8"
: alphaBytes === 2048
? "packed-4"
: "unsupported";
const alphaMapAvailable = Boolean(
alphaChunk.withinMcnk
&& alphaChunk.size + 8 === buffer.readUInt32LE(data + 40)
&& layer.alphaOffset >= 0
&& alphaBytes > 0
&& alphaMapDataOffset + alphaBytes <= alphaChunk.end
&& encoding !== "unsupported",
);
layer.alphaMapBytes = Math.max(0, alphaBytes);
layer.alphaMapDataOffset = alphaMapDataOffset;
layer.alphaMapEncoding = encoding;
layer.alphaMapAvailable = alphaMapAvailable;
}
const heightData = height?.id === "MCVT" ? buffer.subarray(height.dataOffset, height.end) : null;
return {
ordinal,
indexX,
indexY,
flags,
holeMask,
highResolutionHoles: (flags & 0x10000) !== 0,
lowResolutionTextureMap,
world: { x: worldX, y: worldY, height: worldHeight },
sourceOffsets: {
mcnk: mcnk.offset,
mcvt: height?.dataOffset ?? null,
mcly: layersChunk?.dataOffset ?? null,
mcal: alphaChunk?.dataOffset ?? null,
},
heightSha256: heightData ? hash(heightData) : null,
heightBytes: height?.size ?? 0,
heightSamples: height?.id === "MCVT" && height.size % 4 === 0
? Array.from({ length: height.size / 4 }, (_, index) => buffer.readFloatLE(height.dataOffset + index * 4))
: null,
alphaBytes: alphaChunk?.size ?? 0,
alphaChunkWithinMcnk: alphaChunk?.withinMcnk ?? null,
alphaSizeHeader,
alphaChunkHeaderSize: alphaChunkHeader?.size ?? null,
alphaChunkSizeSource: alphaChunk?.sizeSource ?? null,
// In WotLK MCNK headers sizeMCAL includes the 8-byte MCAL chunk header.
// MVER 18 zero-sized MCAL headers use that enclosing span as the source.
alphaSizeMatchesHeader: alphaChunk ? alphaChunk.size + 8 === buffer.readUInt32LE(data + 40) : buffer.readUInt32LE(data + 40) === 0,
materialLayerChunkWithinMcnk: layersChunk?.withinMcnk ?? null,
layers,
};
});
const layerReferences = new Map();
for (const chunk of terrainChunks) {
for (const layer of chunk.layers) {
const existing = layerReferences.get(layer.texture);
if (existing) existing.count += 1;
else layerReferences.set(layer.texture, { texture: layer.texture, count: 1 });
}
}
return {
sha256: createHash("sha256").update(buffer).digest("hex"),
bytes: buffer.length,
topLevelChunks: chunks.map(({ id, size }) => ({ id, size })),
textureTableCount: textures.length,
textures,
mcnkCount: terrainChunks.length,
chunks: terrainChunks,
layerReferences: [...layerReferences.values()].sort((left, right) => left.texture.localeCompare(right.texture)),
layers: terrainChunks.reduce((count, chunk) => count + chunk.layers.length, 0),
alphaMappedLayers: terrainChunks.reduce((count, chunk) => count + chunk.layers.filter((layer) => layer.usesAlphaMap).length, 0),
alphaMapDataAvailable: terrainChunks.reduce((count, chunk) => count + chunk.layers.filter((layer) => layer.alphaMapAvailable).length, 0),
alphaMapDataMissing: terrainChunks.reduce((count, chunk) => count + chunk.layers.filter((layer) => layer.usesAlphaMap && !layer.alphaMapAvailable).length, 0),
alphaChunksOutOfBounds: terrainChunks.filter((chunk) => chunk.alphaChunkWithinMcnk === false).length,
alphaSizeHeaderMismatches: terrainChunks.filter((chunk) => chunk.alphaSizeMatchesHeader === false).length,
};
}
export function inspectObjMaterialContract(file) {
const obj = fs.readFileSync(file, "utf8");
const materialFile = file.replace(/\.obj$/i, ".mtl");
const mtl = fs.existsSync(materialFile) ? fs.readFileSync(materialFile, "utf8") : "";
const uvLines = obj.split(/\r?\n/).filter((line) => line.startsWith("vt "));
const uniqueUvs = new Set(uvLines.map((line) => line.trim()));
const usedMaterials = [...new Set([...obj.matchAll(/^usemtl\s+(.+)$/gm)].map((match) => match[1].trim()))];
return {
obj: path.basename(file),
mtl: path.basename(materialFile),
vertexUvCount: uvLines.length,
uniqueVertexUvs: uniqueUvs.size,
usedMaterials,
mtlHasDiffuseMap: /^\s*map_Kd\s+/im.test(mtl),
mtlHasTextureName: /\.blp|\.png|\.jpg|\.dds/i.test(mtl),
};
}
export function inspectWowAdtTerrainExport(sourceExportDir) {
const rawRoot = path.join(sourceExportDir, "raw", "world", "maps");
if (!fs.existsSync(rawRoot)) throw new Error(`Missing raw ADT directory: ${rawRoot}`);
const adtFiles = [];
const walk = (directory) => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const child = path.join(directory, entry.name);
if (entry.isDirectory()) walk(child);
else if (entry.isFile() && entry.name.toLowerCase().endsWith(".adt")) adtFiles.push(child);
}
};
walk(rawRoot);
if (!adtFiles.length) throw new Error(`No raw ADT files found under ${rawRoot}`);
const terrainFiles = adtFiles.sort().map((file) => {
const parsed = parseWowAdtTerrain(fs.readFileSync(file), { label: file });
const basename = path.basename(file).replace(/\.adt$/i, "");
const coordinates = basename.match(/_(\d+)_(\d+)$/);
const exporterObj = coordinates
? path.join(sourceExportDir, `adt_${coordinates[1]}_${coordinates[2]}.obj`)
: null;
return {
file: path.relative(sourceExportDir, file).replaceAll("\\", "/"),
...parsed,
exporterContract: exporterObj && fs.existsSync(exporterObj) ? inspectObjMaterialContract(exporterObj) : null,
};
});
return {
sourceExportDir: path.resolve(sourceExportDir),
tileCount: terrainFiles.length,
mcnkCount: terrainFiles.reduce((sum, tile) => sum + tile.mcnkCount, 0),
layerCount: terrainFiles.reduce((sum, tile) => sum + tile.layers, 0),
alphaMappedLayerCount: terrainFiles.reduce((sum, tile) => sum + tile.alphaMappedLayers, 0),
alphaMapDataAvailableCount: terrainFiles.reduce((sum, tile) => sum + tile.alphaMapDataAvailable, 0),
alphaMapDataMissingCount: terrainFiles.reduce((sum, tile) => sum + tile.alphaMapDataMissing, 0),
alphaChunksOutOfBoundsCount: terrainFiles.reduce((sum, tile) => sum + tile.alphaChunksOutOfBounds, 0),
alphaSizeHeaderMismatchCount: terrainFiles.reduce((sum, tile) => sum + tile.alphaSizeHeaderMismatches, 0),
maxLayerCountPerChunk: Math.max(0, ...terrainFiles.flatMap((tile) => tile.chunks.map((chunk) => chunk.layers.length))),
referencedTextures: [...new Set(terrainFiles.flatMap((tile) => tile.textures))].sort((a, b) => a.localeCompare(b)),
tiles: terrainFiles,
};
}
@@ -0,0 +1,85 @@
import test from "node:test";
import assert from "node:assert/strict";
import { parseWowAdtTerrain } from "./wow-adt-terrain-audit.mjs";
import { decodeWowAdtAlphaMap } from "./wow-adt-terrain-material.mjs";
function chunk(name, payload) {
const header = Buffer.alloc(8);
Buffer.from(name, "ascii").reverse().copy(header);
header.writeUInt32LE(payload.length, 4);
return Buffer.concat([header, payload]);
}
function rleFill(value) {
const bytes = [];
let remaining = 4096;
while (remaining) {
const count = Math.min(127, remaining);
bytes.push(0x80 | count, value);
remaining -= count;
}
return Buffer.from(bytes);
}
function zeroSizedMcalAdt({ declaredPayloadBytes = null } = {}) {
const mtex = Buffer.from("Base.blp\0Overlay.blp\0", "utf8");
const layers = Buffer.alloc(32);
layers.writeUInt32LE(0, 0);
layers.writeUInt32LE(0, 4);
layers.writeUInt32LE(1, 16);
layers.writeUInt32LE(0x300, 20);
layers.writeUInt32LE(0, 24);
const alpha = rleFill(91);
const payloadLength = declaredPayloadBytes ?? alpha.length;
const alphaSubchunk = Buffer.alloc(8 + alpha.length);
Buffer.from("MCAL", "ascii").reverse().copy(alphaSubchunk);
alphaSubchunk.writeUInt32LE(0, 4);
alpha.copy(alphaSubchunk, 8);
const mcnkHeader = Buffer.alloc(128);
mcnkHeader.writeUInt32LE(0, 4);
mcnkHeader.writeUInt32LE(0, 8);
mcnkHeader.writeUInt32LE(2, 12);
mcnkHeader.writeUInt32LE(136, 28);
mcnkHeader.writeUInt32LE(176, 36);
mcnkHeader.writeUInt32LE(8 + payloadLength, 40);
mcnkHeader.writeUInt16LE(0xa55a, 60);
mcnkHeader.writeUInt16LE(0x1234, 62);
mcnkHeader.writeUInt16LE(0xabcd, 64);
const mcnk = Buffer.concat([mcnkHeader, chunk("MCLY", layers), alphaSubchunk]);
const mver = Buffer.alloc(4);
mver.writeUInt32LE(18);
return Buffer.concat([chunk("MVER", mver), chunk("MTEX", mtex), chunk("MCNK", mcnk)]);
}
test("MVER 18 zero-sized MCAL headers use the enclosing MCNK size span", () => {
const source = zeroSizedMcalAdt();
const parsed = parseWowAdtTerrain(source, { label: "zero-sized MCAL fixture" });
const target = parsed.chunks[0];
const overlay = target.layers[1];
assert.equal(target.alphaChunkHeaderSize, 0);
assert.equal(target.alphaChunkSizeSource, "mcnk-header");
assert.equal(target.holeMask, 0xa55a);
assert.equal(target.highResolutionHoles, false);
assert.equal(target.lowResolutionTextureMap[0], 0xabcd);
assert.equal(target.alphaSizeMatchesHeader, true);
assert.equal(target.alphaChunkWithinMcnk, true);
assert.equal(overlay.alphaMapEncoding, "rle-8");
assert.equal(overlay.alphaMapAvailable, true);
assert.equal(overlay.alphaMapBytes, target.alphaBytes);
const decoded = decodeWowAdtAlphaMap(source, target, overlay);
assert.equal(decoded.length, 4096);
assert.equal(decoded[0], 91);
assert.equal(decoded[4095], 91);
});
test("MVER 18 MCAL spans still fail closed when the declared payload leaves MCNK", () => {
const source = zeroSizedMcalAdt({ declaredPayloadBytes: 0xffff });
const parsed = parseWowAdtTerrain(source, { label: "overrun MCAL fixture" });
const target = parsed.chunks[0];
assert.equal(target.alphaChunkWithinMcnk, false);
assert.equal(target.layers[1].alphaMapAvailable, false);
assert.throws(() => decodeWowAdtAlphaMap(source, target, target.layers[1]), /unsupported or invalid MCAL span/);
});
@@ -0,0 +1,121 @@
const WEIGHT_SIZE = 64 * 64;
function assertKnownAlphaLayer(layer, chunk) {
if (!layer.usesAlphaMap) return;
if (!layer.alphaMapAvailable || layer.alphaMapDataOffset == null || !layer.alphaMapBytes) {
throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} layer ${layer.index} has an unsupported or invalid MCAL span.`);
}
}
function decodeRle8(data, label) {
const output = new Uint8Array(WEIGHT_SIZE);
let inputOffset = 0;
let outputOffset = 0;
while (outputOffset < output.length) {
if (inputOffset >= data.length) throw new Error(`${label}: truncated MCAL RLE stream.`);
const control = data[inputOffset++];
const count = control & 0x7f;
if (!count) throw new Error(`${label}: zero-count MCAL RLE command cannot progress.`);
if (outputOffset + count > output.length) throw new Error(`${label}: MCAL RLE expands beyond 4096 samples.`);
if (control & 0x80) {
if (inputOffset >= data.length) throw new Error(`${label}: truncated MCAL fill command.`);
output.fill(data[inputOffset++], outputOffset, outputOffset + count);
} else {
if (inputOffset + count > data.length) throw new Error(`${label}: truncated MCAL copy command.`);
output.set(data.subarray(inputOffset, inputOffset + count), outputOffset);
inputOffset += count;
}
outputOffset += count;
}
if (inputOffset !== data.length) throw new Error(`${label}: ${data.length - inputOffset} trailing MCAL RLE bytes are unaccounted for.`);
return output;
}
function decodePacked4(data, noEdgeRepair, label) {
if (data.length !== 2048) throw new Error(`${label}: packed 4-bit MCAL must be exactly 2048 bytes.`);
const output = new Uint8Array(WEIGHT_SIZE);
let source = 0;
for (let row = 0; row < 64; row += 1) {
for (let column = 0; column < 64; column += 2) {
const value = data[source++];
output[row * 64 + column] = (value & 0x0f) * 17;
output[row * 64 + column + 1] = (value >>> 4) * 17;
}
}
if (!noEdgeRepair) {
for (let index = 0; index < 64; index += 1) {
output[index * 64 + 63] = output[index * 64 + 62];
output[63 * 64 + index] = output[62 * 64 + index];
}
output[63 * 64 + 63] = output[62 * 64 + 62];
}
return output;
}
export function decodeWowAdtAlphaMap(adtBuffer, chunk, layer) {
assertKnownAlphaLayer(layer, chunk);
if (!layer.usesAlphaMap) return new Uint8Array(WEIGHT_SIZE);
const payload = adtBuffer.subarray(layer.alphaMapDataOffset, layer.alphaMapDataOffset + layer.alphaMapBytes);
const label = `MCNK ${chunk.indexX},${chunk.indexY} layer ${layer.index}`;
switch (layer.alphaMapEncoding) {
case "packed-4":
return decodePacked4(payload, (chunk.flags & 0x8000) !== 0, label);
case "uncompressed-8":
if (payload.length !== WEIGHT_SIZE) throw new Error(`${label}: 8-bit MCAL must be exactly 4096 bytes.`);
return new Uint8Array(payload);
case "rle-8":
return decodeRle8(payload, label);
default:
throw new Error(`${label}: unsupported alpha encoding ${layer.alphaMapEncoding ?? "missing"}.`);
}
}
/**
* Decode source overlays into the effective RGB weights expected by the
* wow-overlay-residual shader. Classic 4-bit maps are source opacity maps;
* their overlay order must be resolved before the shader consumes them.
*/
export function composeWowAdtTerrainWeights(adtBuffer, chunk) {
const overlays = chunk.layers.slice(1);
if (overlays.length > 3) throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} has more than three overlay layers.`);
const encodings = [...new Set(overlays.filter((layer) => layer.usesAlphaMap).map((layer) => layer.alphaMapEncoding))];
if (encodings.length > 1) throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} mixes MCAL alpha encodings.`);
const sourceEncoding = encodings[0] ?? "none";
const alphaMaps = overlays.map((layer) => decodeWowAdtAlphaMap(adtBuffer, chunk, layer));
const packed = sourceEncoding === "packed-4";
const effective = alphaMaps.map((map) => new Uint8Array(map));
if (packed) {
for (let pixel = 0; pixel < WEIGHT_SIZE; pixel += 1) {
let remaining = 255;
for (let index = effective.length - 1; index >= 0; index -= 1) {
const weight = Math.floor((effective[index][pixel] * remaining + 127) / 255);
effective[index][pixel] = weight;
remaining -= weight;
}
}
}
let maxWeightSum = 0;
for (let pixel = 0; pixel < WEIGHT_SIZE; pixel += 1) {
const sum = effective.reduce((value, map) => value + map[pixel], 0);
maxWeightSum = Math.max(maxWeightSum, sum);
if (sum > 255) throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} effective overlays exceed 255 (${sum} at sample ${pixel}).`);
}
const rgba = Buffer.alloc(WEIGHT_SIZE * 4, 255);
for (let pixel = 0; pixel < WEIGHT_SIZE; pixel += 1) {
for (let channel = 0; channel < 3; channel += 1) rgba[pixel * 4 + channel] = effective[channel]?.[pixel] ?? 0;
}
return {
rgba,
sourceEncoding,
sourcePolicy: packed ? "classic-nibble-to-big-alpha" : "direct-8bit-overlay-weights",
channelLayerIndices: overlays.map((layer) => layer.index),
maxWeightSum,
};
}
export function wowAdtNormalizedUv(row, column) {
if (!Number.isInteger(row) || row < 0 || row > 16) throw new RangeError("MCVT row must be in [0,16].");
const columns = row % 2 ? 8 : 9;
if (!Number.isInteger(column) || column < 0 || column >= columns) throw new RangeError(`MCVT column must be in [0,${columns - 1}].`);
return [(column + (row % 2 ? 0.5 : 0)) / 8, row / 16];
}
@@ -0,0 +1,77 @@
import test from "node:test";
import assert from "node:assert/strict";
import { composeWowAdtTerrainWeights, decodeWowAdtAlphaMap, wowAdtNormalizedUv } from "./wow-adt-terrain-material.mjs";
function layer(index, alphaMapEncoding, alphaMapDataOffset, alphaMapBytes, flags = 0x100) {
return {
index,
usesAlphaMap: (flags & 0x100) !== 0,
alphaMapCompressed: (flags & 0x200) !== 0,
alphaMapAvailable: true,
alphaMapEncoding,
alphaMapDataOffset,
alphaMapBytes,
alphaOffset: alphaMapDataOffset,
flags,
};
}
test("classic 4-bit maps expand and get deterministic edge repair", () => {
const bytes = Buffer.alloc(2048, 0x21);
const chunk = { indexX: 4, indexY: 7, flags: 0, layers: [] };
const decoded = decodeWowAdtAlphaMap(bytes, chunk, layer(1, "packed-4", 0, 2048));
assert.equal(decoded[0], 17);
assert.equal(decoded[1], 34);
assert.equal(decoded[63], decoded[62]);
assert.equal(decoded[63 * 64], decoded[62 * 64]);
chunk.flags = 0x8000;
const untouched = decodeWowAdtAlphaMap(bytes, chunk, layer(1, "packed-4", 0, 2048));
assert.equal(untouched[63], 34);
assert.equal(untouched[63 * 64], 17);
});
test("classic 4-bit opacity layers become ordered residual weights", () => {
const bytes = Buffer.alloc(4096, 0x88);
const chunk = {
indexX: 0,
indexY: 0,
flags: 0x8000,
layers: [
{ index: 0, usesAlphaMap: false },
layer(1, "packed-4", 0, 2048),
layer(2, "packed-4", 2048, 2048),
],
};
const weights = composeWowAdtTerrainWeights(bytes, chunk);
assert.equal(weights.sourcePolicy, "classic-nibble-to-big-alpha");
assert.deepEqual(weights.channelLayerIndices, [1, 2]);
assert.equal(weights.rgba[0], 63); // earlier overlay receives only remaining coverage
assert.equal(weights.rgba[1], 136); // last layer remains topmost
assert.equal(weights.rgba[2], 0);
assert.equal(weights.rgba[3], 255);
assert.equal(weights.maxWeightSum, 199);
});
test("compressed 8-bit MCAL fills exactly one 64x64 map", () => {
const packed = [];
let remaining = 4096;
while (remaining) {
const count = Math.min(127, remaining);
packed.push(0x80 | count, 37);
remaining -= count;
}
const bytes = Buffer.from(packed);
const chunk = { indexX: 1, indexY: 2, flags: 0x8000, layers: [] };
const decoded = decodeWowAdtAlphaMap(bytes, chunk, layer(1, "rle-8", 0, bytes.length, 0x300));
assert.equal(decoded.length, 4096);
assert.equal(decoded[0], 37);
assert.equal(decoded[4095], 37);
assert.throws(() => decodeWowAdtAlphaMap(Buffer.from([0x81]), chunk, layer(1, "rle-8", 0, 1, 0x300)), /truncated MCAL fill/);
});
test("normalized MCVT UVs follow source-positive local rows and stagger", () => {
assert.deepEqual(wowAdtNormalizedUv(0, 0), [0, 0]);
assert.deepEqual(wowAdtNormalizedUv(1, 0), [0.0625, 0.0625]);
assert.deepEqual(wowAdtNormalizedUv(16, 8), [1, 1]);
});