Update Healer Man server

This commit is contained in:
phenom committed 2026-10-07 13:05:04 -04:00
1 parent 69809f7228
commit ff20ab8779
101 files changed
+16713 -945

No files matched your search

@@ -0,0 +1,573 @@
#!/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;
}