Files
healer-man/scripts/asset-pipeline/refresh-creature-shipping-metadata.mjs
2026-08-14 15:56:39 -04:00

208 lines
7.3 KiB
JavaScript

#!/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));