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