forked from phenom/healer-man
574 lines
28 KiB
JavaScript
574 lines
28 KiB
JavaScript
#!/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;
|
||
}
|