Files
healer-man/scripts/runewaker-pipeline/verify-native-uv-source-layout.mjs
T
2026-10-06 22:25:34 -04:00

58 lines
3.9 KiB
JavaScript

import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
const [beforeFile, afterFile, reportFile] = process.argv.slice(2);
assert.ok(beforeFile && afterFile && reportFile, "Usage: verify-native-uv-source-layout.mjs <before-canonical.glb> <candidate-canonical.glb> <proof.json>");
const digest = bytes => createHash("sha256").update(bytes).digest("hex");
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
function layout(document) {
const root = document.getRoot();
const graph = root.listNodes().map(node => ({ name: node.getName(), matrix: node.getMatrix(),
children: node.listChildren().map(child => child.getName()).sort() })).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
const triangles = [];
for (const node of root.listNodes()) for (const primitive of node.getMesh()?.listPrimitives() ?? []) {
assert.equal(primitive.getMode(), 4, "Source UV audit requires static triangle primitives.");
assert.equal(primitive.listTargets().length, 0, "Morphed source geometry requires its own layout proof.");
// UV1 is the explicitly adapted presentation stream. Every other source
// attribute and ordered triangle corner is compared independent of grouping.
const semantics = primitive.listSemantics().filter(name => name !== "TEXCOORD_1").sort();
const position = primitive.getAttribute("POSITION");
assert.ok(position);
const indices = primitive.getIndices();
const count = indices?.getCount() ?? position.getCount();
for (let offset = 0; offset < count; offset += 3) {
const corners = [];
for (let corner = 0; corner < 3; corner++) {
const index = indices ? indices.getScalar(offset + corner) : offset + corner;
corners.push(semantics.map(name => primitive.getAttribute(name).getElement(index, [])));
}
triangles.push(JSON.stringify([node.getName(), semantics, corners]));
}
}
triangles.sort();
const textures = root.listTextures().map(texture => ({ name: texture.getName(), sha256: digest(texture.getImage()) }));
return { graph, triangles, textures };
}
const before = layout(await io.read(beforeFile));
const after = layout(await io.read(afterFile));
assert.deepEqual(after.graph, before.graph, "Source hierarchy and node transforms changed.");
assert.equal(after.triangles.length, before.triangles.length, "Source triangle count changed.");
for (let index = 0; index < before.triangles.length; index++) {
assert.equal(after.triangles[index], before.triangles[index], `Decoded source triangle ${index} changed.`);
}
for (const texture of before.textures) {
assert.ok(after.textures.some(candidate => candidate.name === texture.name && candidate.sha256 === texture.sha256),
`Original embedded source image ${texture.name} changed or was removed.`);
}
const report = { beforeFile, beforeSha256: digest(await readFile(beforeFile)), afterFile, afterSha256: digest(await readFile(afterFile)),
hierarchyAndTransformsPreserved: true, orderedSourceTriangleCornersPreserved: true,
allSourceAttributesExceptAdaptedUv1Preserved: true, originalSourceImagesPreserved: true,
triangleCount: before.triangles.length, originalSourceImageCount: before.textures.length,
addedSourceRegionImages: after.textures.filter(texture => !before.textures.some(original => original.name === texture.name)),
caveat: "Presentation UV1, primitive/material grouping and BIN/index layout can change; decoded geometry, normals, UV0 and all other source attributes remain identical." };
await writeFile(reportFile, `${JSON.stringify(report, null, 2)}\n`);
console.log(JSON.stringify({ triangles: report.triangleCount, originalSourceImages: report.originalSourceImageCount, addedSourceRegionImages: report.addedSourceRegionImages.length, reportFile }));