#!/usr/bin/env node import { createHash } from "node:crypto"; import { access, open, readFile, readdir, rename, rm, stat, writeFile, } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const dungeonRoot = path.join(projectRoot, "public", "assets", "game", "dungeons"); const sharedRoot = path.join(projectRoot, "public", "assets", "game", "creatures", "shared"); const registryFile = path.join(projectRoot, "src", "game", "generated", "fivePlayerDungeonImports.json"); const reportFile = path.join( projectRoot, "dungeon-pipeline", "reports", "runtime-creature-animation-optimization.json", ); const TEXTURE_POLICY = "ktx2-creature-v1"; const exists = (file) => access(file).then(() => true, () => false); const readJson = async (file) => JSON.parse(await readFile(file, "utf8")); const writeJson = (file, value) => writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); async function sha256(file) { const hash = createHash("sha256"); const handle = await open(file, "r"); try { for await (const chunk of handle.createReadStream({ autoClose: false })) hash.update(chunk); } finally { await handle.close(); } return hash.digest("hex"); } async function inspectTextures(file) { const handle = await open(file, "r"); try { const header = Buffer.alloc(20); await handle.read(header, 0, header.length, 0); if (header.toString("ascii", 0, 4) !== "glTF") throw new Error(`${file}: invalid GLB magic.`); const jsonLength = header.readUInt32LE(12); const json = Buffer.alloc(jsonLength); await handle.read(json, 0, jsonLength, 20); const gltf = JSON.parse(json.toString("utf8").trimEnd()); return { textureCount: gltf.textures?.length ?? 0, ktx2Textures: (gltf.images ?? []).filter((image) => image.mimeType === "image/ktx2").length, fallbackTextures: (gltf.images ?? []).filter((image) => image.mimeType !== "image/ktx2").length, }; } finally { await handle.close(); } } function textureCompressionMetadata(inspection) { return { policy: TEXTURE_POLICY, color: "ETC1S quality 180", data: "UASTC level 2, RDO lambda 1, Zstandard 18", ...inspection, }; } function runCatalogCompiler() { return new Promise((resolve, reject) => { const child = spawn( process.execPath, [path.join(projectRoot, "scripts", "dungeon-pipeline", "compile-campaign-catalog.mjs")], { cwd: projectRoot, windowsHide: true, stdio: "inherit" }, ); child.once("error", reject); child.once("exit", (code) => { if (code === 0) resolve(); else reject(new Error(`Campaign catalog compiler exited with ${code}.`)); }); }); } const manifestFiles = []; for (const entry of await readdir(dungeonRoot, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const file = path.join(dungeonRoot, entry.name, "dungeon-pack.json"); if (await exists(file)) manifestFiles.push(file); } manifestFiles.sort(); const manifests = await Promise.all(manifestFiles.map(readJson)); const descriptorsByUrl = new Map(); for (const manifest of manifests) { for (const model of Object.values(manifest.creatureModels ?? {})) { if (typeof model.url !== "string") continue; const descriptors = descriptorsByUrl.get(model.url) ?? []; descriptors.push(model); descriptorsByUrl.set(model.url, descriptors); } } const results = []; for (const [url, descriptors] of [...descriptorsByUrl].sort(([left], [right]) => left.localeCompare(right))) { const file = path.join(projectRoot, "public", url.replace(/^\/+/, "")); const checksum = await sha256(file); const details = await stat(file); const inspection = await inspectTextures(file); let output = file; let outputUrl = url; if (url.startsWith("/assets/game/creatures/shared/")) { output = path.join(sharedRoot, `${checksum}.glb`); outputUrl = `/assets/game/creatures/shared/${checksum}.glb`; if (output !== file) { if (!await exists(output)) await rename(file, output); else { if (await sha256(output) !== checksum) throw new Error(`${outputUrl}: hash collision.`); await rm(file, { force: true }); } } } const textureCompression = textureCompressionMetadata(inspection); for (const descriptor of descriptors) { descriptor.url = outputUrl; descriptor.checksum = checksum; descriptor.byteLength = details.size; descriptor.textureCompression = textureCompression; } results.push({ oldUrl: url, url: outputUrl, file: output, checksum, byteLength: details.size, textureCompression, }); } for (let index = 0; index < manifestFiles.length; index += 1) { await writeJson(manifestFiles[index], manifests[index]); } await writeJson(registryFile, { schemaVersion: 1, generatedAt: "deterministic", imports: manifests.slice().sort((left, right) => left.slug.localeCompare(right.slug)), }); const report = await readJson(reportFile); const resultsByOldUrl = new Map(results.map((result) => [result.oldUrl, result])); let localBytes = 0; for (const model of report.models ?? []) { const result = resultsByOldUrl.get(model.url); if (!result) throw new Error(`Optimization report references missing creature ${model.url}.`); model.url = result.url; model.checksum = result.checksum; model.byteLength = result.byteLength; model.textureCompression = result.textureCompression; localBytes += result.byteLength; } let sharedBytes = 0; for (const model of report.shared?.models ?? []) { const result = resultsByOldUrl.get(model.url); if (!result) throw new Error(`Optimization report references missing shared creature ${model.url}.`); model.url = result.url; model.checksum = result.checksum; model.byteLength = result.byteLength; model.textureCompression = result.textureCompression; sharedBytes += result.byteLength; } if (report.totals) { report.totals.runtimeBytes = localBytes; report.totals.savedBytes = report.totals.previousBytes - localBytes; } if (report.shared?.totals) { report.shared.totals.runtimeBytes = sharedBytes; report.shared.totals.savedBytes = report.shared.totals.previousBytes - sharedBytes; } if (report.shippedTotals) { report.shippedTotals.runtimeBytes = localBytes + sharedBytes; report.shippedTotals.savedBytes = report.shippedTotals.previousBytes - localBytes - sharedBytes; } report.textureCompression = { policy: TEXTURE_POLICY, files: results.length, textures: results.reduce((total, result) => total + result.textureCompression.textureCount, 0), ktx2Textures: results.reduce((total, result) => total + result.textureCompression.ktx2Textures, 0), fallbackTextures: results.reduce( (total, result) => total + result.textureCompression.fallbackTextures, 0, ), runtimeBytes: localBytes + sharedBytes, }; await writeJson(reportFile, report); await runCatalogCompiler(); console.log(JSON.stringify({ status: "green", policy: TEXTURE_POLICY, files: results.length, sharedFiles: results.filter((result) => result.url.startsWith("/assets/game/creatures/shared/")).length, ktx2Textures: report.textureCompression.ktx2Textures, fallbackTextures: report.textureCompression.fallbackTextures, runtimeBytes: report.textureCompression.runtimeBytes, }, null, 2));