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

574 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 ?? "<missing>"} 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;
}