143 lines
8.9 KiB
JavaScript
143 lines
8.9 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import { constants } from "node:fs";
|
|
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
// Promotion consumes reviewed, immutable candidates rather than regenerating
|
|
// them. Hash guards and exclusive backups protect unrelated continuation work.
|
|
const vine = process.argv.includes("--vine");
|
|
const stage = vine ? "artifacts/playtest/rom-deep-review/origin-root-card-attribution"
|
|
: "artifacts/playtest/rom-deep-review/origin-tree-alpha";
|
|
const field = vine ? "originVineAlpha" : "originTreeAlpha";
|
|
const cutoff = (vine ? 170 : 192) / 255;
|
|
const imageName = vine ? "72d48adc_elves_vine" : "da39b4ca_elves_gaint_tree";
|
|
const proof = JSON.parse(await readFile(`${stage}/staged-patch-proof.json`, "utf8"));
|
|
const visual = JSON.parse(await readFile(`${stage}/visual-ab-proof.json`, "utf8"));
|
|
assert.equal(visual.accepted, true, "Same-camera, served-identity tree review must be accepted first.");
|
|
const digest = bytes => createHash("sha256").update(bytes).digest("hex");
|
|
function glbParts(bytes) {
|
|
assert.equal(bytes.toString("ascii", 0, 4), "glTF");
|
|
assert.equal(bytes.readUInt32LE(8), bytes.length);
|
|
const jsonLength = bytes.readUInt32LE(12);
|
|
const json = JSON.parse(bytes.toString("utf8", 20, 20 + jsonLength));
|
|
const binStart = 20 + jsonLength + 8;
|
|
return { json, bin: bytes.subarray(binStart) };
|
|
}
|
|
function verifyVinePayloadPreservation(beforeBytes, afterBytes, patchedMaterials, role) {
|
|
const before = glbParts(beforeBytes), after = glbParts(afterBytes);
|
|
for (const key of ["accessors", "meshes", "nodes", "scenes", "scene", "textures", "samplers", "extensions"]) {
|
|
assert.deepEqual(after.json[key], before.json[key], `Origin ${key} changed outside the vine material/image patch.`);
|
|
}
|
|
assert.equal(after.json.materials.length, before.json.materials.length);
|
|
for (const [index, material] of before.json.materials.entries()) {
|
|
const candidate = after.json.materials[index];
|
|
if (!patchedMaterials.includes(index)) assert.deepEqual(candidate, material);
|
|
else {
|
|
const strip = value => { const { alphaMode, alphaCutoff, extras, ...rest } = value; return rest; };
|
|
assert.deepEqual(strip(candidate), strip(material));
|
|
assert.equal(candidate.alphaMode, "MASK");
|
|
assert.equal(candidate.alphaCutoff, cutoff);
|
|
for (const [key, value] of Object.entries(material.extras ?? {})) {
|
|
if (key !== "semanticRole") assert.deepEqual(candidate.extras?.[key], value);
|
|
}
|
|
}
|
|
}
|
|
const target = before.json.images.map((image, index) => image.name === imageName ? index : -1).filter(index => index >= 0);
|
|
assert.equal(target.length, role === "canonical-source" ? 2 : 1);
|
|
const targetViews = new Set(target.map(index => before.json.images[index].bufferView));
|
|
if (role === "canonical-source") assert.ok(after.bin.equals(before.bin), "Canonical vine patch changed source BIN.");
|
|
assert.equal(after.json.images.length, before.json.images.length);
|
|
for (const [index, image] of before.json.images.entries()) if (!target.includes(index)) assert.deepEqual(after.json.images[index], image);
|
|
for (const [index, view] of before.json.bufferViews.entries()) {
|
|
if (targetViews.has(index)) continue;
|
|
assert.deepEqual(after.json.bufferViews[index], view);
|
|
const offset = view.byteOffset ?? 0;
|
|
assert.ok(after.bin.subarray(offset, offset + view.byteLength).equals(before.bin.subarray(offset, offset + view.byteLength)),
|
|
`Non-target Origin bufferView ${index} changed.`);
|
|
}
|
|
}
|
|
const canonical = proof.stagedCopies.find(row => row.role === "canonical-source");
|
|
const shipping = proof.stagedCopies.find(row => row.role === "shipping-copy");
|
|
assert.ok(canonical && shipping && proof.stagedCopies.length === 3);
|
|
if (vine) {
|
|
assert.deepEqual(proof.replacementKtx2.dimensions, [256, 256]);
|
|
assert.equal(proof.replacementKtx2.mipLevels, 9);
|
|
assert.equal(proof.replacementKtx2.sourcePixelsPassingThreshold, 10407);
|
|
assert.equal(proof.replacementKtx2.decodedPixelsPassingThreshold, 10407);
|
|
assert.deepEqual(proof.replacementKtx2.thresholdMaskMismatches, { transparentBecamePassing: 0, opaqueBecameFailing: 0 });
|
|
assert.equal(digest(await readFile(proof.replacementKtx2.file)), proof.replacementKtx2.sha256);
|
|
}
|
|
assert.ok(visual.before.servedResponses.some(row => row.status === 200 && row.bodySha256 === shipping.inputSha256));
|
|
assert.ok(visual.after.servedResponses.some(row => row.status === 200 && row.bodySha256 === shipping.outputSha256));
|
|
assert.deepEqual(visual.before.errors, []);
|
|
assert.deepEqual(visual.after.errors, []);
|
|
assert.equal(visual.before.targetMaterialFirstHit, true);
|
|
assert.equal(visual.after.targetMaterialFirstHit, true);
|
|
assert.equal(visual.after.firstHit.texture, imageName);
|
|
assert.equal(visual.after.firstHit.alphaTest, cutoff);
|
|
assert.deepEqual(visual.before.camera.eye, visual.after.camera.eye);
|
|
assert.deepEqual(visual.before.camera.target, visual.after.camera.target);
|
|
assert.deepEqual(visual.before.lighting, visual.after.lighting);
|
|
for (const phase of [visual.before, visual.after]) {
|
|
assert.equal(digest(await readFile(phase.screenshot)), phase.screenshotSha256);
|
|
}
|
|
assert.equal(proof.gltfPolicy.alphaMode, "MASK");
|
|
assert.equal(proof.gltfPolicy.alphaCutoff, cutoff);
|
|
assert.equal(proof.gltfPolicy.lighting, "standard and unchanged");
|
|
const family = JSON.parse(await readFile(proof.sourceFamilyProof, "utf8"));
|
|
assert.equal(family.sourceRows.length, vine ? 2 : 7);
|
|
assert.equal(family.sharedDDSIdentity.sha256, proof.sourceTextureDdsSha256);
|
|
for (const row of family.sourceRows) {
|
|
assert.equal(digest(await readFile(row.nativeExporterReport)), row.nativeExporterReportSha256);
|
|
assert.equal(digest(await readFile(row.sourceDDS)), row.sourceDDSSha256);
|
|
assert.equal(digest(await readFile(row.preparedPNG)), row.preparedPNGSha256);
|
|
}
|
|
for (const row of proof.stagedCopies) {
|
|
if (!vine || row.role === "canonical-source") assert.equal(row.nonJsonChunksByteIdentical, true);
|
|
else assert.equal(row.nonTargetBufferViewsByteIdentical, true);
|
|
assert.equal(row.geometryWaterLightingAndAllNonTargetJsonPreserved, true);
|
|
const beforeBytes = await readFile(row.input), afterBytes = await readFile(row.output);
|
|
assert.equal(digest(beforeBytes), row.inputSha256, `${row.input} changed since staging.`);
|
|
assert.equal(digest(afterBytes), row.outputSha256, `${row.output} changed since review.`);
|
|
if (vine) verifyVinePayloadPreservation(beforeBytes, afterBytes, row.patchedMaterialIndexes, row.role);
|
|
}
|
|
const review = { stagedProof: `${stage}/staged-patch-proof.json`, visualProof: `${stage}/visual-ab-proof.json`, sourceFamilyProof: proof.sourceFamilyProof, sourceTextureDdsSha256: proof.sourceTextureDdsSha256, canonicalSha256: canonical.outputSha256, shippingSha256: shipping.outputSha256, alphaMode: "MASK", alphaCutoff: cutoff, geometryWaterLightingAndAllNonTargetJsonPreserved: true };
|
|
const edits = [];
|
|
for (const directory of ["src/assets/game/dungeons/the-origin", "runewaker-export-work/the-origin/converted"]) {
|
|
for (const name of ["import-metadata.json", "optimization-report.json", "conversion-report.json"]) {
|
|
const file = `${directory}/${name}`;
|
|
const data = JSON.parse(await readFile(file, "utf8"));
|
|
if (name === "import-metadata.json") {
|
|
const asset = data.assets.find(row => row.role === "visual");
|
|
assert.equal(asset.sha256, shipping.inputSha256);
|
|
asset.sha256 = shipping.outputSha256;
|
|
asset.size = shipping.outputBytes;
|
|
assert.equal(data.nativeMaterialSemantics.assembledSourceSha256, canonical.inputSha256);
|
|
data.nativeMaterialSemantics.assembledSourceSha256 = canonical.outputSha256;
|
|
data.continuationReview = { ...data.continuationReview, [field]: review };
|
|
} else if (name === "optimization-report.json") {
|
|
assert.equal(data.source.checksum, canonical.inputSha256);
|
|
assert.equal(data.optimized.checksum, shipping.inputSha256);
|
|
data.source.checksum = canonical.outputSha256;
|
|
data.source.size = canonical.outputBytes;
|
|
data.optimized.checksum = shipping.outputSha256;
|
|
data.optimized.size = shipping.outputBytes;
|
|
data[field] = review;
|
|
} else {
|
|
data.continuationIntegrityProof = { ...data.continuationIntegrityProof, [field]: review };
|
|
}
|
|
edits.push({ file, data });
|
|
}
|
|
}
|
|
const backup = `${stage}/pre-promotion-backup`;
|
|
for (const file of [...proof.stagedCopies.map(row => row.input), ...edits.map(row => row.file)]) {
|
|
const destination = path.join(backup, file);
|
|
await mkdir(path.dirname(destination), { recursive: true });
|
|
await copyFile(file, destination, constants.COPYFILE_EXCL);
|
|
}
|
|
for (const row of proof.stagedCopies) await copyFile(row.output, row.input);
|
|
for (const row of edits) await writeFile(row.file, `${JSON.stringify(row.data, null, 2)}\n`);
|
|
await writeFile(`${stage}/promotion-proof.json`, `${JSON.stringify({ promoted: proof.stagedCopies.map(row => ({ file: row.input, sha256: row.outputSha256 })), backup, review }, null, 2)}\n`);
|
|
console.log(JSON.stringify({ promoted: proof.stagedCopies.map(row => row.input), backup }));
|