Files
2026-10-07 13:05:04 -04:00

266 lines
12 KiB
JavaScript

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