#!/usr/bin/env node import { createHash } from "node:crypto"; import { copyFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { dungeonEnvironmentFinalPackingArguments, dungeonEnvironmentOptimizationArguments } from "./optimize-environment.mjs"; import { preprocessShadowfangVisual } from "./shadowfang-fallback-preprocess.mjs"; import { projectRoot, workRoot } from "./lib.mjs"; const DEFAULT_OUTPUT = path.join(projectRoot, "artifacts/dungeon-pipeline/shadowfang-keep-visual.selective-untextured.glb"); const BASELINE_VISUAL = path.join(projectRoot, "artifacts/playtest/wow-environment-quality/before-shadowfang/visual.glb"); const KTX2_IDENTIFIER = Buffer.from([0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a]); function sha256(bytes) { return createHash("sha256").update(bytes).digest("hex"); } function relative(file) { return path.relative(projectRoot, file).replaceAll("\\", "/"); } function parseGlb(bytes, label) { if (bytes.length < 20 || bytes.toString("utf8", 0, 4) !== "glTF" || bytes.readUInt32LE(4) !== 2 || bytes.readUInt32LE(8) !== bytes.length) { throw new Error(`${label} is not a complete binary glTF 2.0 file.`); } let json = null; let binary = null; let offset = 12; while (offset + 8 <= bytes.length) { const length = bytes.readUInt32LE(offset); const type = bytes.readUInt32LE(offset + 4); const start = offset + 8; const end = start + length; if (end > bytes.length) throw new Error(`${label} contains a truncated GLB chunk.`); if (type === 0x4e4f534a) json = JSON.parse(bytes.toString("utf8", start, end).replace(/\0+$/u, "").trim()); if (type === 0x004e4942) binary = bytes.subarray(start, end); offset = end; } if (!json || !binary) throw new Error(`${label} must contain JSON and BIN GLB chunks.`); return { json, binary }; } function embeddedImageHashes(buffer, label) { const parsed = parseGlb(buffer, label); return (parsed.json.images ?? []).map((image, index) => { const view = parsed.json.bufferViews?.[image.bufferView]; if (!view) throw new Error(`${label} image ${index} references a missing buffer view.`); const start = view.byteOffset ?? 0; const end = start + view.byteLength; const bytes = parsed.binary.subarray(start, end); if (end > parsed.binary.length) throw new Error(`${label} image ${index} exceeds the GLB BIN chunk.`); if (image.mimeType !== "image/ktx2" || !bytes.subarray(0, 12).equals(KTX2_IDENTIFIER)) { throw new Error(`${label} image ${index} is not a valid embedded KTX2 texture.`); } return { index, name: image.name ?? "", hash: sha256(bytes), byteLength: bytes.length }; }); } function runCli(projectDirectory, args) { const cli = path.join(projectDirectory, "node_modules/@gltf-transform/cli/bin/cli.js"); return new Promise((resolve, reject) => { const child = spawn(process.execPath, [cli, ...args], { cwd: projectDirectory, stdio: "inherit", windowsHide: true }); child.once("error", reject); child.once("exit", (code) => code === 0 ? resolve() : reject(new Error(`gltf-transform exited with code ${code}.`))); }); } function compressKtx2(projectDirectory, file) { const compressor = path.join(projectDirectory, "scripts/asset-pipeline/compress-ktx2.mjs"); return new Promise((resolve, reject) => { const child = spawn(process.execPath, [compressor, file, "--concurrency", "1", "--jobs", "2"], { cwd: projectDirectory, stdio: "inherit", windowsHide: true, }); child.once("error", reject); child.once("exit", (code) => code === 0 ? resolve() : reject(new Error(`KTX2 compression exited with code ${code}.`))); }); } async function main() { const outputArgument = process.argv.indexOf("--output"); const output = path.resolve(outputArgument >= 0 ? process.argv[outputArgument + 1] : DEFAULT_OUTPUT); const errorArgument = process.argv.indexOf("--simplify-error"); const simplifyError = errorArgument >= 0 ? Number(process.argv[errorArgument + 1]) : undefined; if (simplifyError !== undefined && (!Number.isFinite(simplifyError) || simplifyError < 0)) { throw new Error("--simplify-error must be a non-negative number."); } const artifactRoot = path.join(projectRoot, "artifacts"); const outputRelative = path.relative(artifactRoot, output); if (!output.toLowerCase().endsWith(".glb") || outputRelative.startsWith("..") || path.isAbsolute(outputRelative)) { throw new Error("Output must be a .glb candidate inside the repository artifacts directory."); } const source = path.join(workRoot, "shadowfang-keep/staging/environment/shadowfang-keep-visual.glb"); const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "shadowfang-selective-")); const preprocessed = path.join(temporaryDirectory, "shadowfang-selective.glb"); const optimized = path.join(temporaryDirectory, "shadowfang-optimized.glb"); const finalPacked = path.join(temporaryDirectory, "shadowfang-meshopt-final.glb"); try { const preprocessing = await preprocessShadowfangVisual(source, preprocessed, { simplifyError }); await runCli(projectRoot, dungeonEnvironmentOptimizationArguments(preprocessed, optimized)); await compressKtx2(projectRoot, optimized); await runCli(projectRoot, dungeonEnvironmentFinalPackingArguments(optimized, finalPacked)); await mkdir(path.dirname(output), { recursive: true }); await copyFile(finalPacked, output); const candidateBytes = await readFile(output); const candidateTextures = embeddedImageHashes(candidateBytes, output); let baselineTextureComparison = null; if (await stat(BASELINE_VISUAL).then(() => true, () => false)) { const baselineTextures = embeddedImageHashes(await readFile(BASELINE_VISUAL), BASELINE_VISUAL); const baselineByName = new Map(baselineTextures.map((image) => [image.name, image])); const comparisons = candidateTextures.map((image) => { const baseline = baselineByName.get(image.name); return { name: image.name, matched: Boolean(baseline), identicalBytes: Boolean(baseline && baseline.hash === image.hash) }; }); baselineTextureComparison = { baseline: relative(BASELINE_VISUAL), matched: comparisons.filter((row) => row.matched).length, identicalBytes: comparisons.filter((row) => row.identicalBytes).length, candidateImageCount: candidateTextures.length, }; if (baselineTextureComparison.matched !== candidateTextures.length || baselineTextureComparison.identicalBytes !== candidateTextures.length) { throw new Error("Selective candidate KTX2 image bytes differ from the preserved published Shadowfang baseline."); } } const report = { schemaVersion: 1, dungeonId: "shadowfang-keep", source: preprocessing.source, output: { file: relative(output), sha256: sha256(candidateBytes), byteLength: candidateBytes.length }, simplification: { ...preprocessing.profile, api: "@gltf-transform/functions simplifyPrimitive", texturedPrimitiveAccessorHashesUnchanged: preprocessing.texturedPrimitiveAccessorHashesUnchanged, sourceTextureBytesUnchanged: preprocessing.sourceTextureBytesUnchanged, selectedByMaterial: preprocessing.selectedByMaterial, }, textureEncoding: { codec: "ETC1S/BasisLZ from the existing KTX2 compressor policy", imageCount: candidateTextures.length, comparedToPreservedBaseline: baselineTextureComparison, }, meshoptQuantization: { positionBits: 16, texcoordBits: 16 }, finalPacking: { command: "gltf-transform meshopt --level high --quantize-position 16 --quantize-texcoord 16", extensionPresent: parseGlb(candidateBytes, output).json.extensionsUsed?.includes("EXT_meshopt_compression") ?? false, }, }; const reportFile = `${output}.report.json`; await writeFile(reportFile, `${JSON.stringify(report, null, 2)}\n`, "utf8"); console.log(JSON.stringify({ ...report, reportFile: relative(reportFile) }, null, 2)); } finally { await rm(temporaryDirectory, { recursive: true, force: true }); } } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { await main(); }