#!/usr/bin/env node import { createHash } from "node:crypto"; import { copyFile, readFile, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(scriptDirectory, "../.."); const publicRoot = path.join(projectRoot, "public"); const KTX2_IDENTIFIER = Buffer.from([0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a]); async function readJson(file) { return JSON.parse(await readFile(file, "utf8")); } async function writeJson(file, value) { await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } function parseGlb(buffer, label) { if (buffer.length < 20 || buffer.toString("utf8", 0, 4) !== "glTF" || buffer.readUInt32LE(4) !== 2 || buffer.readUInt32LE(8) !== buffer.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 <= buffer.length) { const length = buffer.readUInt32LE(offset); const type = buffer.readUInt32LE(offset + 4); const start = offset + 8; const end = start + length; if (end > buffer.length) throw new Error(`${label} contains a truncated GLB chunk.`); const chunk = buffer.subarray(start, end); if (type === 0x4e4f534a) json = JSON.parse(chunk.toString("utf8").replace(/\0+$/u, "").trim()); if (type === 0x004e4942) binary = chunk; offset = end; } if (!json || !binary) throw new Error(`${label} must contain JSON and BIN chunks.`); return { json, binary }; } function inspectCandidate(buffer, label) { const { json, binary } = parseGlb(buffer, label); if (!json.extensionsUsed?.includes("EXT_meshopt_compression") || !json.bufferViews?.some((view) => view.extensions?.EXT_meshopt_compression?.byteLength > 0)) { throw new Error(`${label} must retain EXT_meshopt_compression buffer views after KTX2 encoding.`); } let triangleCount = 0; for (const mesh of json.meshes ?? []) { for (const primitive of mesh.primitives ?? []) { if (primitive.mode !== undefined && primitive.mode !== 4) continue; const accessorIndex = primitive.indices ?? primitive.attributes?.POSITION; const accessor = json.accessors?.[accessorIndex]; if (!Number.isInteger(accessor?.count) || accessor.count % 3 !== 0) { throw new Error(`${label} has an invalid triangle accessor.`); } triangleCount += accessor.count / 3; } } const images = json.images ?? []; if (!images.length || images.some((image) => image.mimeType !== "image/ktx2")) { throw new Error(`${label} must contain only embedded KTX2 images.`); } for (const [index, image] of images.entries()) { const view = json.bufferViews?.[image.bufferView]; if (!view) throw new Error(`${label} image ${index} references a missing buffer view.`); const start = view.byteOffset ?? 0; const bytes = binary.subarray(start, start + view.byteLength); if (bytes.length < 48 || !bytes.subarray(0, 12).equals(KTX2_IDENTIFIER)) { throw new Error(`${label} image ${index} has an invalid KTX2 header.`); } } return { triangleCount, imageCount: images.length, meshoptBufferViewCount: json.bufferViews.filter((view) => view.extensions?.EXT_meshopt_compression).length, }; } function updateVisualDescriptors(targets, descriptor) { for (const { label, packageDefinition } of targets) { const visual = packageDefinition?.visual?.[0]; if (!visual || visual.url !== descriptor.url) { throw new Error(`${label} visual URL does not match target ${descriptor.url}.`); } } for (const { packageDefinition } of targets) { Object.assign(packageDefinition.visual[0], { checksum: descriptor.checksum, byteLength: descriptor.byteLength, triangleCount: descriptor.triangleCount, compression: "meshopt+ktx2", }); } } function resolvePublicAsset(url, label) { if (typeof url !== "string" || !url.startsWith("/assets/game/")) { throw new Error(`${label} does not have a canonical public asset URL.`); } const file = path.resolve(publicRoot, url.replace(/^\//u, "")); const relative = path.relative(publicRoot, file); if (relative.startsWith("..") || path.isAbsolute(relative)) { throw new Error(`${label} URL escapes the public asset root.`); } return file; } async function collisionNavigationHashes(stagePack) { const assets = [ ...(stagePack.collision ?? []).map((asset) => ({ label: asset.id, ...asset })), ...(stagePack.navigation ? [{ label: "navigation", ...stagePack.navigation }] : []), ]; return Promise.all(assets.map(async (asset) => { const file = resolvePublicAsset(asset.url, asset.label); const bytes = await readFile(file); const checksum = createHash("sha256").update(bytes).digest("hex"); if (asset.checksum && checksum !== asset.checksum) { throw new Error(`${asset.label} checksum does not match stage-pack metadata before visual publish.`); } return { id: asset.label, url: asset.url, checksum, byteLength: bytes.length }; })); } async function main() { const [, , slug, candidateArgument, ...flags] = process.argv; if (!slug || !candidateArgument || flags.some((flag) => flag !== "--apply")) { throw new Error("Usage: node scripts/dungeon-pipeline/apply-wow-visual-optimization.mjs [--apply]"); } const apply = flags.includes("--apply"); const candidatePath = path.resolve(projectRoot, candidateArgument); const candidateBytes = await readFile(candidatePath); const candidateStats = inspectCandidate(candidateBytes, candidatePath); const checksum = createHash("sha256").update(candidateBytes).digest("hex"); const byteLength = (await stat(candidatePath)).size; const dungeonPackPath = path.join(publicRoot, "assets/game/dungeons", slug, "dungeon-pack.json"); const dungeonPack = await readJson(dungeonPackPath); const environmentVisualUrl = dungeonPack.environment?.visual?.[0]?.url; if (!environmentVisualUrl) throw new Error(`${dungeonPackPath} has no canonical environment visual URL.`); const stagePackPath = path.resolve(publicRoot, path.posix.join( path.posix.dirname(environmentVisualUrl.replace(/^\/+/, "")), "stage-pack.json", )); if (!await stat(stagePackPath).then(() => true, () => false)) { throw new Error(`${slug} does not resolve to a canonical public Manastorm package.`); } const stagePack = await readJson(stagePackPath); const visual = stagePack.visual?.[0]; if (!visual?.url) throw new Error(`${stagePackPath} has no canonical visual descriptor.`); const visualPath = path.resolve(publicRoot, visual.url.replace(/^\/+/, "")); const relativeVisual = path.relative(publicRoot, visualPath); if (relativeVisual.startsWith("..") || path.isAbsolute(relativeVisual)) { throw new Error(`${slug} visual URL escapes the public asset root.`); } const descriptor = { url: visual.url, checksum, byteLength, triangleCount: candidateStats.triangleCount, }; const assetRegistryPath = path.join(projectRoot, "src/game/generated/manastormAssetPackages.json"); const assetRegistry = await readJson(assetRegistryPath); const registryPackage = assetRegistry.packages.find((entry) => entry.id === stagePack.id); if (!registryPackage) throw new Error(`Generated Manastorm registry has no package ${stagePack.id}.`); const importRegistryPath = path.join(projectRoot, "src/game/generated/fivePlayerDungeonImports.json"); const importRegistry = await readJson(importRegistryPath); const dungeonImport = importRegistry.imports.find((entry) => entry.slug === slug); if (!dungeonImport || dungeonImport.environment?.id !== stagePack.id) { throw new Error(`Generated five-player imports have no matching ${slug} environment ${stagePack.id}.`); } if (dungeonPack.environment?.id !== stagePack.id) { throw new Error(`${dungeonPackPath} does not point to ${stagePack.id}.`); } const updateTargets = [ { label: "Manastorm stage pack", packageDefinition: stagePack }, { label: "Generated Manastorm registry", packageDefinition: registryPackage }, { label: "Generated five-player import", packageDefinition: dungeonImport.environment }, { label: "Dungeon package", packageDefinition: dungeonPack.environment }, ]; for (const target of updateTargets) { const targetVisual = target.packageDefinition?.visual?.[0]; if (!targetVisual || targetVisual.url !== descriptor.url) { throw new Error(`${target.label} visual URL does not match target ${descriptor.url}.`); } } const collisionNavigationBefore = await collisionNavigationHashes(stagePack); const pendingVisualPath = `${visualPath}.pending`; const plan = { slug, candidate: path.relative(projectRoot, candidatePath).replaceAll("\\", "/"), publishedVisual: path.relative(projectRoot, visualPath).replaceAll("\\", "/"), checksum, byteLength, triangleCount: candidateStats.triangleCount, imageCount: candidateStats.imageCount, meshoptBufferViewCount: candidateStats.meshoptBufferViewCount, collisionNavigationHashesBefore: collisionNavigationBefore, apply, }; if (!apply) { console.log(JSON.stringify(plan, null, 2)); return; } updateVisualDescriptors(updateTargets, descriptor); await copyFile(candidatePath, pendingVisualPath); await copyFile(pendingVisualPath, visualPath); await rm(pendingVisualPath, { force: true }); await Promise.all([ writeJson(stagePackPath, stagePack), writeJson(assetRegistryPath, assetRegistry), writeJson(importRegistryPath, importRegistry), writeJson(dungeonPackPath, dungeonPack), ]); const collisionNavigationAfter = await collisionNavigationHashes(await readJson(stagePackPath)); if (JSON.stringify(collisionNavigationAfter) !== JSON.stringify(collisionNavigationBefore)) { throw new Error("Collision or navigation assets changed during visual publishing."); } console.log(JSON.stringify({ ...plan, collisionNavigationHashesAfter: collisionNavigationAfter }, null, 2)); } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { await main(); } export { updateVisualDescriptors };