Update Healer Man server
This commit is contained in:
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]);
|
||||
});
|
||||
Reference in new issue
Block a user