#!/usr/bin/env node import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { Matrix4, Vector3 } from "three"; import { NodeIO } from "@gltf-transform/core"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { decodeBlp2, encodePng } from "../lib/blp.mjs"; import { parseWowAdtTerrain, inspectObjMaterialContract } from "./lib/wow-adt-terrain-audit.mjs"; import { composeWowAdtTerrainWeights, wowAdtNormalizedUv } from "./lib/wow-adt-terrain-material.mjs"; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const defaultWork = path.resolve(projectRoot, "../HealerMan-Storage/pipeline-work/dungeons/zulfarrak/source-export"); const defaultAdt = path.join(defaultWork, "raw/world/maps/tanarisinstance/tanarisinstance_30_29.adt"); const defaultObj = path.join(defaultWork, "adt_30_29.obj"); const defaultGlb = path.resolve(projectRoot, "../HealerMan-Storage/pipeline-work/dungeons/zulfarrak/staging/environment/zulfarrak-visual.glb"); const defaultDungeonWork = path.dirname(defaultWork); const defaultProvenanceLock = path.join(defaultDungeonWork, "provenance.lock.json"); const defaultSourceSnapshot = path.join(defaultDungeonWork, "source-snapshot.json"); const defaultMpqDirectory = path.resolve(projectRoot, "../wow335a/Data"); const args = new Map(); for (let index = 2; index < process.argv.length; index += 1) { const key = process.argv[index]; if (!key.startsWith("--") || !process.argv[index + 1] || process.argv[index + 1].startsWith("--")) { throw new Error(`Expected --name value, got ${key}.`); } args.set(key, process.argv[++index]); } const readArg = (name, fallback) => path.resolve(args.get(name) ?? fallback); const adtPath = readArg("--adt", defaultAdt); const objPath = readArg("--obj", defaultObj); const glbPath = readArg("--glb", defaultGlb); const provenanceLockPath = readArg("--provenance-lock", defaultProvenanceLock); const sourceSnapshotPath = readArg("--source-snapshot", defaultSourceSnapshot); const mpqDirectory = readArg("--mpq-dir", defaultMpqDirectory); const worldAdtPath = (args.get("--world-path") ?? "World/Maps/TanarisInstance/TanarisInstance_30_29.adt").replaceAll("\\", "/"); const outputRoot = readArg("--out", path.join( projectRoot, "artifacts/playtest/wow-terrain-recovery", `zulfarrak-30-29-${new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-")}-${process.pid}`, )); for (const input of [adtPath, objPath, glbPath, provenanceLockPath, sourceSnapshotPath]) { if (!fs.existsSync(input)) throw new Error(`Missing required source: ${input}`); } if (!fs.existsSync(mpqDirectory)) throw new Error(`Missing MPQ directory: ${mpqDirectory}`); const provenanceLock = JSON.parse(fs.readFileSync(provenanceLockPath, "utf8")); const sourceSnapshot = JSON.parse(fs.readFileSync(sourceSnapshotPath, "utf8")); const clientBuild = provenanceLock.clientBuild; if (provenanceLock.dungeonId !== "zulfarrak" || typeof clientBuild !== "string" || !clientBuild.trim()) { throw new Error(`The source provenance lock does not identify a Zul'Farrak client build: ${provenanceLockPath}`); } if (sourceSnapshot.client?.build !== clientBuild) { throw new Error(`The source snapshot build ${sourceSnapshot.client?.build ?? ""} does not match provenance lock ${clientBuild}.`); } fs.mkdirSync(path.dirname(outputRoot), { recursive: true }); fs.mkdirSync(outputRoot, { recursive: false }); const require = createRequire(import.meta.url); const { MpqArchive } = require("stormlib-js"); const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); const safeRelative = (value) => { const normalized = value.replaceAll("\\", "/").replace(/^\/+/, ""); if (!normalized || normalized.split("/").some(part => part === ".." || part === ".")) { throw new Error(`Unsafe source-relative path: ${value}`); } return normalized; }; const adt = fs.readFileSync(adtPath); const parsed = parseWowAdtTerrain(adt, { label: adtPath }); if (parsed.mcnkCount !== 256 || parsed.chunks.some(chunk => chunk.heightSamples?.length !== 145)) { throw new Error("The selected terrain input is not one complete 16x16 MCVT ADT tile."); } if (parsed.chunks.some(chunk => chunk.highResolutionHoles)) { throw new Error("High-resolution MCNK holes are present; this prototype only preserves the 3.3.5 low-resolution mask."); } if (parsed.alphaMapDataMissing) { throw new Error(`Refusing incomplete source alpha data: ${parsed.alphaMapDataMissing} overlay maps are unsupported or out of bounds.`); } const adtSha256 = sha256(adt); const sourcePathRelative = safeRelative(worldAdtPath); const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); const gltf = await io.read(glbPath); const meshNode = gltf.getRoot().listNodes().find(node => node.getName() === "ADT_30_29"); if (!meshNode?.getMesh() || meshNode.getMesh().listPrimitives().length !== 1) { throw new Error("Could not identify the one-primitive ADT_30_29 shipping-source mesh."); } const primitive = meshNode.getMesh().listPrimitives()[0]; const positionAccessor = primitive.getAttribute("POSITION"); const normalAccessor = primitive.getAttribute("NORMAL"); const uvAccessor = primitive.getAttribute("TEXCOORD_0"); const indexAccessor = primitive.getIndices(); if (!positionAccessor || !normalAccessor || !uvAccessor || !indexAccessor) throw new Error("Source GLB is missing POSITION, NORMAL, TEXCOORD_0, or indices."); const positions = positionAccessor.getArray(); const normals = normalAccessor.getArray(); const sourceUvs = uvAccessor.getArray(); const indices = indexAccessor.getArray(); if (positionAccessor.getCount() !== parsed.mcnkCount * 145 || normalAccessor.getCount() !== positionAccessor.getCount() || uvAccessor.getCount() !== positionAccessor.getCount() || indexAccessor.getCount() !== parsed.mcnkCount * 768) { throw new Error("The source GLB vertex/index layout differs from the 256 × 145 WoW ADT chunk layout."); } const objText = fs.readFileSync(objPath, "utf8"); const objPositions = objText.split(/\r?\n/).filter(line => line.startsWith("v ")) .map(line => line.trim().split(/\s+/).slice(1).map(Number)); if (objPositions.length !== positionAccessor.getCount()) throw new Error("OBJ and staging GLB vertex counts differ."); const materialContract = inspectObjMaterialContract(objPath); const worldMatrix = new Matrix4().fromArray(meshNode.getWorldMatrix()); const worldPosition = new Vector3(); const unitsPerChunkCell = 533.33333 / 16 / 8; const uvStream = new Float32Array(positionAccessor.getCount() * 2); let maxSourceToObjError = 0; let maxSourceToShippingWorldError = 0; let sourceToObjViolations = 0; let sourceToShippingWorldViolations = 0; const maxSourceToObjTolerance = 0.0001; const maxSourceToShippingWorldTolerance = 0.001; for (const chunk of parsed.chunks) { let vertexOffset = 0; for (let row = 0; row <= 16; row += 1) { const columnCount = row % 2 ? 8 : 9; for (let column = 0; column < columnCount; column += 1) { const height = chunk.heightSamples[vertexOffset]; const sourcePosition = [ chunk.world.x - column * unitsPerChunkCell - (row % 2 ? unitsPerChunkCell / 2 : 0), chunk.world.y + height, chunk.world.height - row * unitsPerChunkCell / 2, ]; const globalVertex = chunk.ordinal * 145 + vertexOffset; const objPosition = objPositions[globalVertex]; const sourceToObjError = Math.max(...sourcePosition.map((value, axis) => Math.abs(value - objPosition[axis]))); maxSourceToObjError = Math.max(maxSourceToObjError, sourceToObjError); if (sourceToObjError > maxSourceToObjTolerance) sourceToObjViolations += 1; worldPosition.set( positions[globalVertex * 3], positions[globalVertex * 3 + 1], positions[globalVertex * 3 + 2], ).applyMatrix4(worldMatrix); const shippingWorld = [worldPosition.x, worldPosition.y, worldPosition.z]; const sourceToShippingError = Math.max(...sourcePosition.map((value, axis) => Math.abs(value - shippingWorld[axis]))); maxSourceToShippingWorldError = Math.max(maxSourceToShippingWorldError, sourceToShippingError); if (sourceToShippingError > maxSourceToShippingWorldTolerance) sourceToShippingWorldViolations += 1; const [u, v] = wowAdtNormalizedUv(row, column); uvStream[globalVertex * 2] = u; uvStream[globalVertex * 2 + 1] = v; vertexOffset += 1; } } } if (sourceToObjViolations || sourceToShippingWorldViolations) { throw new Error(`Source geometry correspondence failed: OBJ ${sourceToObjViolations}, staging GLB ${sourceToShippingWorldViolations}.`); } const indexElementBytes = indexAccessor.getArray().BYTES_PER_ELEMENT; if (![1, 2, 4].includes(indexElementBytes)) throw new Error(`Unsupported index element size ${indexElementBytes}.`); const retainedIndices = []; const chunkIndexRanges = []; let holeTrianglesRemoved = 0; for (const chunk of parsed.chunks) { const sourceIndexStart = chunk.ordinal * 768; const sourceIndexEnd = sourceIndexStart + 768; const rowStarts = []; let rowStart = 0; for (let row = 0; row <= 16; row += 1) { rowStarts[row] = rowStart; rowStart += row % 2 ? 8 : 9; } for (let fanRow = 0; fanRow < 8; fanRow += 1) { for (let fanColumn = 0; fanColumn < 8; fanColumn += 1) { const groupStart = sourceIndexStart + (fanRow * 8 + fanColumn) * 12; const groupEnd = groupStart + 12; const centerIndex = chunk.ordinal * 145 + rowStarts[fanRow * 2 + 1] + fanColumn; let centerMentions = 0; for (let index = groupStart; index < groupEnd; index += 1) { if (index < sourceIndexStart || index >= sourceIndexEnd) throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} triangle group leaves its shipping index range.`); if (indices[index] < chunk.ordinal * 145 || indices[index] >= (chunk.ordinal + 1) * 145) { throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} index ${index} points into another chunk's vertices.`); } if (indices[index] === centerIndex) centerMentions += 1; } if (centerMentions !== 4) throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} source index topology is not the verified 64-fan MCVT layout.`); const holeBit = Math.floor(fanRow / 2) * 4 + Math.floor(fanColumn / 2); if ((chunk.holeMask & (1 << holeBit)) !== 0) { holeTrianglesRemoved += 4; } else { for (let index = groupStart; index < groupEnd; index += 1) retainedIndices.push(indices[index]); } } } const removedHoleFans = countRemovedFans(chunk.holeMask); const outputIndexStart = retainedIndices.length - (768 - removedHoleFans * 12); chunkIndexRanges.push({ ordinal: chunk.ordinal, indexX: chunk.indexX, indexY: chunk.indexY, vertexStart: chunk.ordinal * 145, vertexCount: 145, sourceIndexStart, sourceIndexCount: 768, recoveredIndexStart: outputIndexStart, recoveredIndexCount: 768 - removedHoleFans * 12, sourceHoleMaskHex: chunk.holeMask.toString(16).padStart(4, "0"), highResolutionHoles: chunk.highResolutionHoles, }); } if (retainedIndices.length + holeTrianglesRemoved * 3 !== indices.length) { throw new Error("Source hole masks do not account for the recovered triangle index count."); } const indexArray = indexAccessor.getArray().constructor.from(retainedIndices); const archiveNames = ["patch-3.MPQ", "patch-2.MPQ", "patch.MPQ", "lichking.MPQ", "expansion.MPQ", "common-2.MPQ", "common.MPQ"]; const directoryEntries = new Map(fs.readdirSync(mpqDirectory, { withFileTypes: true }) .filter(entry => entry.isFile()).map(entry => [entry.name.toLowerCase(), path.join(mpqDirectory, entry.name)])); const archives = []; const archiveErrors = []; for (const archiveName of archiveNames) { const archivePath = directoryEntries.get(archiveName.toLowerCase()); if (!archivePath) continue; try { archives.push({ file: archivePath, archive: MpqArchive.open(archivePath, { noListfile: true, noAttributes: true }) }); } catch (error) { archiveErrors.push({ file: archivePath, message: error.message }); } } if (!archives.length) throw new Error(`No readable WoW MPQ archives found under ${mpqDirectory}.`); const adtArchiveSources = []; try { for (const entry of archives) { if (entry.archive.hasFile(worldAdtPath)) { const bytes = entry.archive.extractFile(worldAdtPath); adtArchiveSources.push({ archive: path.basename(entry.file), sha256: sha256(bytes), bytes: bytes.length }); } } const winningAdtSource = adtArchiveSources[0]; if (!winningAdtSource || winningAdtSource.sha256 !== adtSha256) { throw new Error(`Source ADT does not match the highest-priority available MPQ copy for ${worldAdtPath}.`); } const textureCarriers = []; const textureVariantByIndex = []; for (const [textureIndex, textureName] of parsed.textures.entries()) { let bytes = null; let archiveName = null; for (const entry of archives) { if (!entry.archive.hasFile(textureName)) continue; bytes = entry.archive.extractFile(textureName); archiveName = path.basename(entry.file); break; } if (!bytes) throw new Error(`Native texture missing from configured client MPQs: ${textureName}`); const textureSha256 = sha256(bytes); const relativeBlp = `textures/source/${safeRelative(textureName)}`; const relativePng = `textures/decoded/${safeRelative(textureName).replace(/\.blp$/i, ".png")}`; const blpOutput = path.join(outputRoot, relativeBlp); const pngOutput = path.join(outputRoot, relativePng); fs.mkdirSync(path.dirname(blpOutput), { recursive: true }); fs.mkdirSync(path.dirname(pngOutput), { recursive: true }); fs.writeFileSync(blpOutput, bytes); const decodedImage = decodeBlp2(bytes, textureName); const decoded = encodePng(decodedImage); fs.writeFileSync(pngOutput, decoded); const variant = `wowTerrain.layer.${textureSha256}`; textureVariantByIndex[textureIndex] = variant; textureCarriers.push({ textureIndex, sourceTexture: textureName, sourceArchive: archiveName, sourceBytes: bytes.length, sourceSha256: textureSha256, width: decodedImage.width, height: decodedImage.height, sourceBlp: relativeBlp, decodedPng: relativePng, decodedPngSha256: sha256(decoded), variant, }); } const weightCarriers = new Map(); const materials = []; const terrainChunks = []; for (const chunk of parsed.chunks) { const weights = composeWowAdtTerrainWeights(adt, chunk); const channelStats = [0, 1, 2, 3].map(channel => { const values = new Set(); let min = 255; let max = 0; for (let pixel = channel; pixel < weights.rgba.length; pixel += 4) { const value = weights.rgba[pixel]; values.add(value); min = Math.min(min, value); max = Math.max(max, value); } return { channel: "rgba"[channel], uniqueValues: values.size, min, max }; }); let weightVariant = null; let weightImage = null; if (chunk.layers.length > 1) { const png = encodePng({ width: 64, height: 64, pixels: weights.rgba }); const pngSha256 = sha256(png); weightVariant = `wowTerrain.weight.${pngSha256}`; weightImage = `textures/weights/${pngSha256}.png`; if (!weightCarriers.has(weightVariant)) { fs.mkdirSync(path.dirname(path.join(outputRoot, weightImage)), { recursive: true }); fs.writeFileSync(path.join(outputRoot, weightImage), png); weightCarriers.set(weightVariant, { variant: weightVariant, image: weightImage, sha256: pngSha256 }); } } const layerVariants = chunk.layers.map(layer => textureVariantByIndex[layer.textureIndex]); const material = { name: `wowTerrain_30_29_${String(chunk.indexX).padStart(2, "0")}_${String(chunk.indexY).padStart(2, "0")}`, sourceChunk: { ordinal: chunk.ordinal, indexX: chunk.indexX, indexY: chunk.indexY, flags: chunk.flags, sourceOffsets: chunk.sourceOffsets, layers: chunk.layers.map(layer => ({ index: layer.index, recordOffset: layer.recordOffset, textureIndex: layer.textureIndex, sourceTexture: layer.texture, flags: layer.flags, alphaOffset: layer.alphaOffset, alphaMapEncoding: layer.alphaMapEncoding, alphaMapDataOffset: layer.alphaMapDataOffset, alphaMapBytes: layer.alphaMapBytes, alphaMapSha256: layer.alphaMapAvailable ? sha256(adt.subarray(layer.alphaMapDataOffset, layer.alphaMapDataOffset + layer.alphaMapBytes)) : null, })), alphaChunkSizeSource: chunk.alphaChunkSizeSource, alphaChunkHeaderSize: chunk.alphaChunkHeaderSize, alphaSizeHeader: chunk.alphaSizeHeader, alphaSizeMatchesHeader: chunk.alphaSizeMatchesHeader, holeMask: chunk.holeMask, lowResolutionTextureMap: chunk.lowResolutionTextureMap, }, wowTerrain: { schemaVersion: 1, sourceAdt: { path: sourcePathRelative, sha256: adtSha256, clientBuild, archive: winningAdtSource.archive, matchingTileSha256: winningAdtSource.sha256, }, layerVariants, weightVariants: weightVariant ? [weightVariant] : [], uv: { attribute: "TEXCOORD_0", basis: "mcnk-local-normalized", diffuseScale: [8, 8], weightScale: [1, 1], vSign: 1, }, composition: { rule: "wow-overlay-residual", normalize: false }, }, weight: { sourceEncoding: weights.sourceEncoding, sourcePolicy: weights.sourcePolicy, channelLayerIndices: weights.channelLayerIndices, maxWeightSum: weights.maxWeightSum, channelStats, variant: weightVariant, image: weightImage, rgbaSha256: sha256(weights.rgba), }, }; materials.push(material); terrainChunks.push({ ordinal: chunk.ordinal, indexX: chunk.indexX, indexY: chunk.indexY, material: material.name, sourceHeightSamples: chunk.heightSamples, sourceHeightSha256: chunk.heightSha256, sourceHoleMaskHex: chunk.holeMask.toString(16).padStart(4, "0"), vertexUvStart: chunk.ordinal * 145, vertexUvCount: 145, uvMin: [0, 0], uvMax: [1, 1], }); } const entrance = [Number(args.get("--entrance-x") ?? 841.59), Number(args.get("--entrance-z") ?? 1213.52)]; const nearestEntranceChunk = parsed.chunks.map(chunk => ({ indexX: chunk.indexX, indexY: chunk.indexY, center: [chunk.world.x - unitsPerChunkCell * 4, chunk.world.height - unitsPerChunkCell * 4], })).map(chunk => ({ ...chunk, distance: Math.hypot(chunk.center[0] - entrance[0], chunk.center[1] - entrance[1]) })) .sort((left, right) => left.distance - right.distance)[0]; const uvBytes = Buffer.from(uvStream.buffer, uvStream.byteOffset, uvStream.byteLength); const indexBytes = Buffer.from(indexArray.buffer, indexArray.byteOffset, indexArray.byteLength); const chunkGeometryByOrdinal = new Map(); const chunkGeometryFiles = []; for (const range of chunkIndexRanges) { const chunk = parsed.chunks[range.ordinal]; const vertexStart = range.vertexStart; const vertexEnd = vertexStart + range.vertexCount; const positionSlice = positions.slice(vertexStart * 3, vertexEnd * 3); const normalSlice = normals.slice(vertexStart * 3, vertexEnd * 3); const uvSlice = uvStream.slice(vertexStart * 2, vertexEnd * 2); const indicesGlobal = indexArray.subarray(range.recoveredIndexStart, range.recoveredIndexStart + range.recoveredIndexCount); const localIndices = new indexArray.constructor(indicesGlobal.length); for (let index = 0; index < indicesGlobal.length; index += 1) { const localIndex = indicesGlobal[index] - vertexStart; if (localIndex < 0 || localIndex >= range.vertexCount) { throw new Error(`MCNK ${range.indexX},${range.indexY} recovered index escapes its disjoint vertex slice.`); } localIndices[index] = localIndex; } if (localIndices.length % 3 !== 0) throw new Error(`MCNK ${range.indexX},${range.indexY} has an incomplete triangle.`); const directory = `geometry/chunks/mcnk-${String(range.indexX).padStart(2, "0")}-${String(range.indexY).padStart(2, "0")}`; const streams = [ ["position-float32.bin", positionSlice], ["normal-float32.bin", normalSlice], ["uv0-float32.bin", uvSlice], [`indices-uint${indexElementBytes * 8}.bin`, localIndices], ]; const files = {}; for (const [filename, array] of streams) { const bytes = Buffer.from(array.buffer, array.byteOffset, array.byteLength); const file = `${directory}/${filename}`; chunkGeometryFiles.push([file, bytes]); files[filename.replace(/-.*$/, "").replace("indices", "indices")] = { file, bytes: bytes.length, count: array.length, sha256: sha256(bytes), }; } chunkGeometryByOrdinal.set(range.ordinal, { coordinateSpace: "staging-glb-node-local", position: files.position, normal: files.normal, uv0: files.uv0, indices: files.indices, indexCount: localIndices.length, maxLocalIndex: localIndices.length ? Math.max(...localIndices) : null, vertexStart: range.vertexStart, sourceIndexStart: range.sourceIndexStart, sourceIndexCount: range.sourceIndexCount, recoveredIndexStart: range.recoveredIndexStart, recoveredIndexCount: range.recoveredIndexCount, }); if (positionSlice.length !== 145 * 3 || normalSlice.length !== 145 * 3 || uvSlice.length !== 145 * 2) { throw new Error(`MCNK ${range.indexX},${range.indexY} disjoint attribute slice has an unexpected size.`); } if (chunk.ordinal !== range.ordinal) throw new Error("MCNK ordinal lookup changed during chunk stream extraction."); } for (const terrainChunk of terrainChunks) terrainChunk.geometry = chunkGeometryByOrdinal.get(terrainChunk.ordinal); const outputFiles = [ ["source/tanarisinstance_30_29.adt", adt], ["geometry/uv0-float32.bin", uvBytes], ["geometry/indices-lowres-holes-filtered.bin", indexBytes], ...chunkGeometryFiles, ]; for (const [relativePath, bytes] of outputFiles) { const output = path.join(outputRoot, relativePath); fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, bytes); } const materialsFile = { schemaVersion: 1, title: "Zul'Farrak ADT 30_29 source-backed terrain material pilot", sourceAdt: { path: sourcePathRelative, localSource: adtPath, sha256: adtSha256, bytes: adt.length, mver: adt.readUInt32LE(8), clientBuild, provenanceEvidence: { dungeonId: provenanceLock.dungeonId, lockFile: path.relative(projectRoot, provenanceLockPath).replaceAll("\\", "/"), lockSha256: sha256(fs.readFileSync(provenanceLockPath)), sourceSnapshotFile: path.relative(projectRoot, sourceSnapshotPath).replaceAll("\\", "/"), sourceSnapshotSha256: sha256(fs.readFileSync(sourceSnapshotPath)), sourceSnapshotClientBuild: sourceSnapshot.client.build, }, winningArchive: winningAdtSource.archive, availableArchiveCopies: adtArchiveSources, }, sourceGeometry: { obj: objPath, glb: glbPath, node: meshNode.getName(), nodeWorldMatrix: meshNode.getWorldMatrix(), primitiveCount: 1, vertexCount: positionAccessor.getCount(), indexCount: indexAccessor.getCount(), originalUvCount: uvAccessor.getCount(), sourceUvUniqueCount: new Set(Array.from({ length: uvAccessor.getCount() }, (_, index) => `${sourceUvs[index * 2]},${sourceUvs[index * 2 + 1]}`)).size, originalObjMaterialContract: materialContract, sourceToObjPositionMaxAbsError: maxSourceToObjError, sourceToShippingWorldPositionMaxAbsError: maxSourceToShippingWorldError, sourceToObjTolerance: maxSourceToObjTolerance, sourceToShippingWorldTolerance: maxSourceToShippingWorldTolerance, vertexPositionViolations: { obj: sourceToObjViolations, shippingWorld: sourceToShippingWorldViolations }, uv0Output: "geometry/uv0-float32.bin", uv0Sha256: sha256(uvBytes), uvBasis: "MCVT index-positive local coordinates; row 0→16 maps V 0→1; staggered rows retain their half-cell U offset", sourceIndices: { bytes: indices.byteLength, sha256: sha256(Buffer.from(indices.buffer, indices.byteOffset, indices.byteLength)) }, recoveredIndices: { file: "geometry/indices-lowres-holes-filtered.bin", bytes: indexBytes.length, sha256: sha256(indexBytes), count: indexArray.length }, holePolicy: "preserve raw low-resolution 4x4 MCNK mask and omit each source-marked 2x2 cell fan block; this tile contains no set hole bits", holeTrianglesRemoved, }, tileStatistics: { mcnkCount: parsed.mcnkCount, layerCount: parsed.layers, alphaMappedLayerCount: parsed.alphaMappedLayers, alphaMapsRecovered: parsed.alphaMapDataAvailable, alphaMapMissing: parsed.alphaMapDataMissing, alphaChunksUsingEnclosingMcnkSize: parsed.chunks.filter(chunk => chunk.alphaChunkSizeSource === "mcnk-header").length, nonZeroLowResolutionHoleMasks: parsed.chunks.filter(chunk => chunk.holeMask !== 0).length, referencedTextureCount: parsed.textures.length, nearestEntranceChunk, }, textures: textureCarriers, weightCarriers: [...weightCarriers.values()], materials, chunks: terrainChunks, shippingChunkRanges: chunkIndexRanges, archiveOpenErrors: archiveErrors, }; const json = Buffer.from(`${JSON.stringify(materialsFile, null, 2)}\n`, "utf8"); fs.writeFileSync(path.join(outputRoot, "terrain-materials.json"), json); const readme = [ "# WoW native ADT terrain material pilot", "", "This generated bundle is a source-backed diagnostic for Zul'Farrak ADT 30_29. It does not modify or replace shipping assets.", "", "- `source/tanarisinstance_30_29.adt` is an exact copy of the raw native ADT identified by the source hash in `terrain-materials.json`.", `- Client identity ${clientBuild} is read from and cross-checked between the dungeon provenance lock and source snapshot; both paths and hashes are in terrain-materials.json.`, "- `textures/source/` preserves the original BLP files; `textures/decoded/` contains lossless PNG decodes for inspection and runtime carrier preparation.", "- `textures/weights/` contains one 64×64 RGBA RGB-weight carrier per unique source-composed image.", "- `geometry/uv0-float32.bin` replaces the flat source-export UVs in the same 37,120-vertex order as the staged `ADT_30_29` primitive.", "- `geometry/indices-lowres-holes-filtered.bin` retains that primitive's verified fan topology and applies its native low-resolution hole masks.", "- `geometry/chunks/mcnk-XX-YY/` contains disjoint 145-vertex position, normal, UV0, and local-index streams for each MCNK. Each slice has a SHA-256, making it suitable for one independently culled primitive per chunk; the source staging node world matrix is recorded in the manifest.", "- `terrain-materials.json` includes per-MCNK material metadata, source offsets, layer maps, alpha map spans and hashes, source-vs-shipping geometry checks, and KHR variant carrier names.", "", "The tile currently has no low-resolution hole bits set. Geometry checks establish vertex correspondence, not visual parity or proof of the original client's lighting response.", ].join("\n") + "\n"; fs.writeFileSync(path.join(outputRoot, "README.md"), readme); console.log(JSON.stringify({ outputRoot, adtSha256, tileStatistics: materialsFile.tileStatistics, sourceGeometry: materialsFile.sourceGeometry, textureCount: textureCarriers.length, materialCount: materials.length, uniqueWeightImages: weightCarriers.size }, null, 2)); } finally { for (const entry of archives) entry.archive.close(); } function countRemovedFans(holeMask) { let count = 0; for (let fanRow = 0; fanRow < 8; fanRow += 1) { for (let fanColumn = 0; fanColumn < 8; fanColumn += 1) { const bit = Math.floor(fanRow / 2) * 4 + Math.floor(fanColumn / 2); if (holeMask & (1 << bit)) count += 1; } } return count; }