Files
healer-man/scripts/dungeon-pipeline/apply-wow-visual-optimization.mjs
T
2026-10-06 22:25:34 -04:00

232 lines
10 KiB
JavaScript

#!/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 <slug> <candidate.glb> [--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 };