Files
healer-man/scripts/dungeon-pipeline/lib/wow-adt-terrain-material.mjs
T
2026-10-07 13:05:04 -04:00

122 lines
5.4 KiB
JavaScript

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];
}