#!/usr/bin/env node import { createHash } from "node:crypto"; import { access, mkdir, readFile, readdir, rename, rm, stat, writeFile, } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { Logger, NodeIO } from "@gltf-transform/core"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { getBounds } from "@gltf-transform/functions"; import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer"; import { CREATURE_MESH_CONSOLIDATION_POLICY, consolidateCreatureSkinnedMeshes, } from "./creature-mesh-consolidation.mjs"; import { CREATURE_RUNTIME_ANIMATION_POLICY, semanticCreatureAnimations, trimCreatureRuntimeAnimations, } from "./runtime-creature-animations.mjs"; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const shippingRoot = path.join(projectRoot, "public", "assets", "game", "dungeons"); const defaultSourceRoot = path.resolve( process.env.DUNGEON_PIPELINE_WORK ?? path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons"), ); const registryFile = path.join( projectRoot, "src", "game", "generated", "fivePlayerDungeonImports.json", ); const reportFile = path.join( projectRoot, "dungeon-pipeline", "reports", "runtime-creature-animation-optimization.json", ); const args = process.argv.slice(2); const dryRun = args.includes("--dry-run"); const slugArgument = args.indexOf("--slug"); const onlySlug = slugArgument >= 0 ? args[slugArgument + 1] : null; const limitArgument = args.indexOf("--limit"); const limit = limitArgument >= 0 ? Number.parseInt(args[limitArgument + 1], 10) : null; const sourceArgument = args.indexOf("--source-root"); const sourceRoot = sourceArgument >= 0 ? path.resolve(args[sourceArgument + 1]) : defaultSourceRoot; if (slugArgument >= 0 && !onlySlug) throw new Error("--slug requires a dungeon slug."); if (limitArgument >= 0 && (!Number.isInteger(limit) || limit <= 0)) { throw new Error("--limit requires a positive integer."); } if (sourceArgument >= 0 && !args[sourceArgument + 1]) { throw new Error("--source-root requires a directory."); } async function exists(file) { try { await access(file); return true; } catch { return false; } } async function readJson(file) { return JSON.parse(await readFile(file, "utf8")); } async function writeJson(file, value) { await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } async function sha256(file) { return createHash("sha256").update(await readFile(file)).digest("hex"); } async function listTargets() { const targets = []; for (const dungeon of await readdir(shippingRoot, { withFileTypes: true })) { if (!dungeon.isDirectory() || (onlySlug && dungeon.name !== onlySlug)) continue; const directory = path.join(shippingRoot, dungeon.name, "creatures"); if (!await exists(directory)) continue; for (const entry of await readdir(directory, { withFileTypes: true })) { if (!entry.isFile()) continue; const match = entry.name.match(/^display-(\d+)-animated\.glb$/i); if (!match) continue; const displayId = Number.parseInt(match[1], 10); targets.push({ slug: dungeon.name, displayId, output: path.join(directory, entry.name), source: path.join( sourceRoot, dungeon.name, "creature-source", `display-${displayId}`, `display-${displayId}.glb`, ), url: `/assets/game/dungeons/${dungeon.name}/creatures/${entry.name}`, }); } } targets.sort((left, right) => ( left.slug.localeCompare(right.slug) || left.displayId - right.displayId )); return limit ? targets.slice(0, limit) : targets; } function missingAnimationSemantics(semantics) { return [ !semantics.stand && "stand", !(semantics.walk || semantics.run) && "locomotion", !semantics.attack && "attack", !semantics.death && "death", ].filter(Boolean); } function roundedBounds(bounds) { return { min: bounds.min.map((value) => Number(value.toFixed(6))), max: bounds.max.map((value) => Number(value.toFixed(6))), }; } function protectedNodeInventory(document) { const root = document.getRoot(); const protectedNodes = new Set(); for (const skin of root.listSkins()) { if (skin.getSkeleton()) protectedNodes.add(skin.getSkeleton()); for (const joint of skin.listJoints()) protectedNodes.add(joint); } for (const animation of root.listAnimations()) { for (const channel of animation.listChannels()) { if (channel.getTargetNode()) protectedNodes.add(channel.getTargetNode()); } } return [...protectedNodes].map((node) => node.getName()).sort(); } function materialInventory(document) { return document.getRoot().listMaterials().map((material) => ({ name: material.getName(), alphaMode: material.getAlphaMode(), alphaCutoff: material.getAlphaCutoff(), doubleSided: material.getDoubleSided(), baseColorFactor: material.getBaseColorFactor(), metallicFactor: material.getMetallicFactor(), roughnessFactor: material.getRoughnessFactor(), baseColorTexture: material.getBaseColorTexture()?.getName() ?? null, })); } function updateDescriptors(value, byUrl) { if (Array.isArray(value)) { for (const child of value) updateDescriptors(child, byUrl); return; } if (!value || typeof value !== "object") return; const optimized = typeof value.url === "string" ? byUrl.get(value.url) : null; if (optimized) { value.checksum = optimized.checksum; value.byteLength = optimized.byteLength; value.animationClips = optimized.runtimeClipCount; value.sourceAnimationClips = optimized.sourceClipCount; value.animationOptimization = { policy: CREATURE_RUNTIME_ANIMATION_POLICY, removedClips: optimized.removedClipCount, }; value.meshOptimization = { policy: CREATURE_MESH_CONSOLIDATION_POLICY, sourceDrawCalls: optimized.sourceDrawCalls, runtimeDrawCalls: optimized.runtimeDrawCalls, savedDrawCalls: optimized.savedDrawCalls, }; value.animations = optimized.semantics; value.missingAnimationSemantics = missingAnimationSemantics(optimized.semantics); } for (const child of Object.values(value)) updateDescriptors(child, byUrl); } async function updateManifests(results) { const byUrl = new Map(results.map((result) => [result.url, result])); const slugs = [...new Set(results.map((result) => result.slug))].sort(); for (const slug of slugs) { const manifestFile = path.join(shippingRoot, slug, "dungeon-pack.json"); if (!await exists(manifestFile)) continue; const manifest = await readJson(manifestFile); updateDescriptors(manifest, byUrl); await writeJson(manifestFile, manifest); } const imports = []; for (const entry of await readdir(shippingRoot, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const manifestFile = path.join(shippingRoot, entry.name, "dungeon-pack.json"); if (await exists(manifestFile)) imports.push(await readJson(manifestFile)); } imports.sort((left, right) => left.slug.localeCompare(right.slug)); await writeJson(registryFile, { schemaVersion: 1, generatedAt: "deterministic", imports, }); } async function replaceAtomically(output, temporary) { const backup = `${output}.pre-runtime-animation`; await rm(backup, { force: true }); await rename(output, backup); try { await rename(temporary, output); await rm(backup, { force: true }); } catch (error) { if (await exists(backup) && !await exists(output)) await rename(backup, output); throw error; } } await MeshoptDecoder.ready; await MeshoptEncoder.ready; const io = new NodeIO() .registerExtensions(ALL_EXTENSIONS) .registerDependencies({ "meshopt.decoder": MeshoptDecoder, "meshopt.encoder": MeshoptEncoder, }); const targets = await listTargets(); if (!targets.length) throw new Error("No shipped dungeon creature GLBs matched the requested scope."); const missingSources = []; for (const target of targets) { if (!await exists(target.source)) missingSources.push(target.source); } if (missingSources.length) { throw new Error( `Refusing to replace runtime assets: ${missingSources.length} complete source GLBs are missing.\n` + missingSources.slice(0, 20).join("\n"), ); } console.log(JSON.stringify({ mode: dryRun ? "dry-run" : "apply", policies: { animation: CREATURE_RUNTIME_ANIMATION_POLICY, mesh: CREATURE_MESH_CONSOLIDATION_POLICY, }, targets: targets.length, sourceRoot, }, null, 2)); const results = []; for (let index = 0; index < targets.length; index += 1) { const target = targets[index]; const before = await stat(target.output); const document = await io.read(target.source); document.setLogger(new Logger(Logger.Verbosity.WARN)); const optimization = await trimCreatureRuntimeAnimations(document); if (!optimization.runtimeClipCount) { throw new Error(`${target.slug} display ${target.displayId}: policy retained no animation clips.`); } const sourceScene = document.getRoot().getDefaultScene() ?? document.getRoot().listScenes()[0]; if (!sourceScene) throw new Error(`${target.slug} display ${target.displayId}: GLB has no scene.`); const sourceBounds = roundedBounds(getBounds(sourceScene)); const sourceProtectedNodes = protectedNodeInventory(document); const sourceMaterials = materialInventory(document); const meshOptimization = await consolidateCreatureSkinnedMeshes(document); const temporary = `${target.output}.runtime-animation.tmp.glb`; await rm(temporary, { force: true }); await io.write(temporary, document); const verification = await io.read(temporary); const verifiedNames = verification.getRoot().listAnimations().map((animation) => animation.getName()); if (JSON.stringify(verifiedNames) !== JSON.stringify(optimization.retainedNames)) { await rm(temporary, { force: true }); throw new Error(`${target.slug} display ${target.displayId}: written animation inventory did not verify.`); } const verifiedScene = verification.getRoot().getDefaultScene() ?? verification.getRoot().listScenes()[0]; const verifiedBounds = roundedBounds(getBounds(verifiedScene)); if (JSON.stringify(sourceBounds) !== JSON.stringify(verifiedBounds)) { await rm(temporary, { force: true }); throw new Error(`${target.slug} display ${target.displayId}: mesh consolidation changed bounds.`); } if (JSON.stringify(sourceProtectedNodes) !== JSON.stringify(protectedNodeInventory(verification))) { await rm(temporary, { force: true }); throw new Error(`${target.slug} display ${target.displayId}: protected skeleton/animation nodes changed.`); } if (JSON.stringify(sourceMaterials) !== JSON.stringify(materialInventory(verification))) { await rm(temporary, { force: true }); throw new Error(`${target.slug} display ${target.displayId}: material semantics changed.`); } const outputSize = (await stat(temporary)).size; const checksum = await sha256(temporary); if (!dryRun) { await replaceAtomically(target.output, temporary); } else await rm(temporary, { force: true }); const semantics = semanticCreatureAnimations(optimization.retainedNames); results.push({ slug: target.slug, displayId: target.displayId, url: target.url, sourceClipCount: optimization.sourceClipCount, runtimeClipCount: optimization.runtimeClipCount, removedClipCount: optimization.removedNames.length, sourceDrawCalls: meshOptimization.before.drawCalls, runtimeDrawCalls: meshOptimization.after.drawCalls, savedDrawCalls: meshOptimization.before.drawCalls - meshOptimization.after.drawCalls, meshGroups: meshOptimization.groups, mergedMeshNodes: meshOptimization.mergedNodes, previousByteLength: before.size, byteLength: outputSize, checksum, semantics, }); if ((index + 1) % 25 === 0 || index + 1 === targets.length) { console.log(`Processed ${index + 1}/${targets.length} creature GLBs.`); } } if (!dryRun) await updateManifests(results); const totals = results.reduce((summary, result) => ({ sourceClips: summary.sourceClips + result.sourceClipCount, runtimeClips: summary.runtimeClips + result.runtimeClipCount, sourceDrawCalls: summary.sourceDrawCalls + result.sourceDrawCalls, runtimeDrawCalls: summary.runtimeDrawCalls + result.runtimeDrawCalls, previousBytes: summary.previousBytes + result.previousByteLength, runtimeBytes: summary.runtimeBytes + result.byteLength, }), { sourceClips: 0, runtimeClips: 0, sourceDrawCalls: 0, runtimeDrawCalls: 0, previousBytes: 0, runtimeBytes: 0, }); const byDungeon = Object.values(results.reduce((summary, result) => { summary[result.slug] ??= { slug: result.slug, models: 0, sourceClips: 0, runtimeClips: 0, sourceDrawCalls: 0, runtimeDrawCalls: 0, previousBytes: 0, runtimeBytes: 0, }; const entry = summary[result.slug]; entry.models += 1; entry.sourceClips += result.sourceClipCount; entry.runtimeClips += result.runtimeClipCount; entry.sourceDrawCalls += result.sourceDrawCalls; entry.runtimeDrawCalls += result.runtimeDrawCalls; entry.previousBytes += result.previousByteLength; entry.runtimeBytes += result.byteLength; return summary; }, {})); const report = { schemaVersion: 2, generatedAt: "deterministic", mode: dryRun ? "dry-run" : "apply", policies: { animation: CREATURE_RUNTIME_ANIMATION_POLICY, mesh: CREATURE_MESH_CONSOLIDATION_POLICY, }, totals: { models: results.length, ...totals, removedClips: totals.sourceClips - totals.runtimeClips, savedDrawCalls: totals.sourceDrawCalls - totals.runtimeDrawCalls, savedBytes: totals.previousBytes - totals.runtimeBytes, }, dungeons: byDungeon, models: results, }; if (!dryRun) await writeJson(reportFile, report); console.log(JSON.stringify(report.totals, null, 2)); if (!dryRun) console.log(`Wrote ${path.relative(projectRoot, reportFile).replace(/\\/g, "/")}.`);