266 lines
12 KiB
JavaScript
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,
|
|
};
|
|
}
|