133 lines
9.1 KiB
JavaScript
133 lines
9.1 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
// Diagnostic lossless append: preserve existing images/geometry byte-for-byte.
|
|
// Do not run solid-texture pruning on RGB weight carriers stored through AO.
|
|
const [input, bundleDirectory, output] = process.argv.slice(2);
|
|
if (!input || !bundleDirectory || !output) throw new Error("Provide shipping GLB, source bundle directory, and diagnostic output GLB.");
|
|
const hash = bytes => createHash("sha256").update(bytes).digest("hex");
|
|
const original = await readFile(input);
|
|
if (original.toString("ascii", 0, 4) !== "glTF" || original.readUInt32LE(4) !== 2
|
|
|| original.readUInt32LE(8) !== original.length) throw new Error("Invalid GLB.");
|
|
const jsonLength = original.readUInt32LE(12);
|
|
const json = JSON.parse(original.subarray(20, 20 + jsonLength).toString().trim());
|
|
const binaryHeader = 20 + jsonLength;
|
|
if (original.readUInt32LE(binaryHeader + 4) !== 0x004e4942 || json.buffers.length !== 1) throw new Error("Expected one embedded BIN buffer.");
|
|
const originalBinary = original.subarray(binaryHeader + 8, binaryHeader + 8 + original.readUInt32LE(binaryHeader));
|
|
const manifest = JSON.parse(await readFile(path.join(bundleDirectory, "terrain-materials.json"), "utf8"));
|
|
if (manifest.sourceAdt.sha256 !== "8ad75da6b9485d61537ee3b451c08b79e25b1b1b2c7d4e91c8e57ad0a65ff75b"
|
|
|| manifest.sourceAdt.clientBuild !== "3.3.5a-Ascension"
|
|
|| hash(await readFile(path.join(bundleDirectory, "source/tanarisinstance_30_29.adt"))) !== manifest.sourceAdt.sha256
|
|
|| hash(original) !== "a45ad3ad072fce73d657fb668cab691fc87059a8a0701630465249ce8670d8f0") {
|
|
throw new Error("Pilot inputs must match the verified Ascension source tile and reviewed shipping asset.");
|
|
}
|
|
const targetNodes = json.nodes.filter(node => node.name === manifest.sourceGeometry.node);
|
|
if (targetNodes.length !== 1 || targetNodes[0].children?.length) throw new Error("Ambiguous or nested target terrain node.");
|
|
const target = targetNodes[0];
|
|
const oldMesh = target.mesh;
|
|
const oldPrimitives = json.meshes[oldMesh]?.primitives;
|
|
if (oldPrimitives?.length !== 1 || !/^ADT_Terrain(?:\.\d+)?$/.test(json.materials[oldPrimitives[0].material]?.name ?? "")
|
|
|| json.accessors[oldPrimitives[0].indices].count !== manifest.sourceGeometry.indexCount
|
|
|| json.nodes.filter(node => node.mesh === oldMesh).length !== 1) throw new Error("Target is not the verified single terrain primitive.");
|
|
if (json.nodes.some(node => node.children?.includes(json.nodes.indexOf(target)))) throw new Error("Expected terrain at scene root.");
|
|
// This pilot supports the verified no-hole tile. Other tiles must be recovered independently.
|
|
if (manifest.chunks.length !== 256 || manifest.chunks.some(chunk => chunk.sourceHoleMaskHex !== "0000")) throw new Error("Unverified tile topology.");
|
|
const parts = [originalBinary];
|
|
let byteLength = originalBinary.length;
|
|
function append(bytes, targetKind) {
|
|
const padding = (4 - byteLength % 4) % 4;
|
|
if (padding) { parts.push(Buffer.alloc(padding)); byteLength += padding; }
|
|
const view = json.bufferViews.length;
|
|
json.bufferViews.push({ buffer: 0, byteOffset: byteLength, byteLength: bytes.length, ...(targetKind ? { target: targetKind } : {}) });
|
|
parts.push(bytes); byteLength += bytes.length;
|
|
return view;
|
|
}
|
|
async function verified(file, expected) {
|
|
const absolute = path.resolve(bundleDirectory, file);
|
|
if (!absolute.startsWith(`${path.resolve(bundleDirectory)}${path.sep}`)) throw new Error("Source path escapes bundle.");
|
|
const bytes = await readFile(absolute);
|
|
if (hash(bytes) !== expected) throw new Error(`Source hash mismatch: ${file}`);
|
|
return bytes;
|
|
}
|
|
json.textures ??= []; json.samplers ??= []; json.images ??= [];
|
|
const repeat = json.samplers.push({ wrapS: 10497, wrapT: 10497, minFilter: 9987, magFilter: 9729 }) - 1;
|
|
const clamp = json.samplers.push({ wrapS: 33071, wrapT: 33071, minFilter: 9987, magFilter: 9729 }) - 1;
|
|
json.extensions ??= {};
|
|
json.extensions.KHR_materials_variants ??= { variants: [] };
|
|
json.extensionsUsed = [...new Set([...(json.extensionsUsed ?? []), "KHR_materials_variants"])];
|
|
const variants = json.extensions.KHR_materials_variants.variants;
|
|
const carriers = new Map();
|
|
async function carrier(name, file, sha256, weight) {
|
|
if (carriers.has(name)) throw new Error("Duplicate carrier.");
|
|
const bytes = await verified(file, sha256);
|
|
const image = json.images.push({ name, mimeType: "image/png", bufferView: append(bytes) }) - 1;
|
|
const texture = json.textures.push({ source: image, sampler: weight ? clamp : repeat }) - 1;
|
|
const material = json.materials.push({ name, pbrMetallicRoughness: { metallicFactor: 0, roughnessFactor: 1,
|
|
...(!weight ? { baseColorTexture: { index: texture } } : {}) }, ...(weight ? { occlusionTexture: { index: texture } } : {}) }) - 1;
|
|
const variant = variants.push({ name }) - 1;
|
|
carriers.set(name, { material, variants: [variant] });
|
|
}
|
|
for (const texture of manifest.textures) await carrier(texture.variant, texture.decodedPng, texture.decodedPngSha256, false);
|
|
for (const weight of manifest.weightCarriers) await carrier(weight.variant, weight.image, weight.sha256, true);
|
|
json.meshes.splice(oldMesh, 1);
|
|
for (const node of json.nodes) if (node.mesh > oldMesh) node.mesh -= 1;
|
|
delete target.mesh; delete target.translation; delete target.rotation; delete target.scale;
|
|
target.matrix = manifest.sourceGeometry.nodeWorldMatrix;
|
|
target.children = [];
|
|
const definitions = new Map(manifest.materials.map(material => [material.name, material]));
|
|
for (const chunk of manifest.chunks) {
|
|
const definition = definitions.get(chunk.material);
|
|
const material = json.materials.push({ name: definition.name, doubleSided: true, pbrMetallicRoughness: { metallicFactor: 0, roughnessFactor: 1 },
|
|
extras: { wowTerrain: definition.wowTerrain, sourceChunk: { indexX: chunk.indexX, indexY: chunk.indexY } } }) - 1;
|
|
async function accessor(record, type, componentType, targetKind, bounds = false) {
|
|
const bytes = await verified(record.file, record.sha256);
|
|
if (bytes.length !== record.bytes) throw new Error("Attribute size mismatch.");
|
|
const arity = type === "SCALAR" ? 1 : type === "VEC2" ? 2 : 3;
|
|
const value = { bufferView: append(bytes, targetKind), componentType, type, count: record.count / arity };
|
|
if (bounds) {
|
|
value.min = Array(arity).fill(Infinity); value.max = Array(arity).fill(-Infinity);
|
|
for (let i = 0; i < record.count; i++) {
|
|
const v = bytes.readFloatLE(i * 4); const k = i % arity;
|
|
value.min[k] = Math.min(value.min[k], v); value.max[k] = Math.max(value.max[k], v);
|
|
}
|
|
}
|
|
return json.accessors.push(value) - 1;
|
|
}
|
|
const geometry = chunk.geometry;
|
|
const attributes = {
|
|
POSITION: await accessor(geometry.position, "VEC3", 5126, 34962, true),
|
|
NORMAL: await accessor(geometry.normal, "VEC3", 5126, 34962),
|
|
TEXCOORD_0: await accessor(geometry.uv0, "VEC2", 5126, 34962),
|
|
};
|
|
const mappings = [...definition.wowTerrain.layerVariants, ...definition.wowTerrain.weightVariants].map(name => {
|
|
const mapping = carriers.get(name); if (!mapping) throw new Error(`Missing ${name}`); return mapping;
|
|
});
|
|
const mesh = json.meshes.push({ name: definition.name, primitives: [{ attributes, material,
|
|
indices: await accessor(geometry.indices, "SCALAR", 5123, 34963),
|
|
extensions: { KHR_materials_variants: { mappings } } }] }) - 1;
|
|
target.children.push(json.nodes.push({ name: definition.name, mesh }) - 1);
|
|
}
|
|
json.buffers[0].byteLength = byteLength;
|
|
const binary = Buffer.concat(parts);
|
|
const binaryPadding = (4 - binary.length % 4) % 4;
|
|
const jsonBytes = Buffer.from(JSON.stringify(json));
|
|
const jsonPadding = (4 - jsonBytes.length % 4) % 4;
|
|
const result = Buffer.alloc(28 + jsonBytes.length + jsonPadding + binary.length + binaryPadding);
|
|
result.write("glTF"); result.writeUInt32LE(2, 4); result.writeUInt32LE(result.length, 8);
|
|
result.writeUInt32LE(jsonBytes.length + jsonPadding, 12); result.writeUInt32LE(0x4e4f534a, 16);
|
|
jsonBytes.copy(result, 20); result.fill(0x20, 20 + jsonBytes.length, 20 + jsonBytes.length + jsonPadding);
|
|
const offset = 20 + jsonBytes.length + jsonPadding;
|
|
result.writeUInt32LE(binary.length + binaryPadding, offset); result.writeUInt32LE(0x004e4942, offset + 4);
|
|
binary.copy(result, offset + 8);
|
|
await writeFile(output, result);
|
|
const report = { status: "diagnostic-only", input: { path: input, sha256: hash(original), bytes: original.length },
|
|
output: { path: output, sha256: hash(result), bytes: result.length }, sourceAdt: manifest.sourceAdt,
|
|
sourceBundle: bundleDirectory, chunkCount: manifest.chunks.length, recoveredTriangleCount: manifest.sourceGeometry.indexCount / 3,
|
|
originalBinaryPrefixUnchanged: binary.subarray(0, originalBinary.length).equals(originalBinary),
|
|
originalImagesUnchanged: true, sourceTextures: manifest.textures.length, weightImages: manifest.weightCarriers.length,
|
|
compression: "original payloads retained; new source textures are lossless PNG and disjoint geometry is float32/uint16",
|
|
limits: "One source-backed tile. No original-client color/shadow reconstruction or fleet publication." };
|
|
await writeFile(`${output}.report.json`, `${JSON.stringify(report, null, 2)}\n`);
|
|
console.log(JSON.stringify(report, null, 2));
|