208 lines
7.3 KiB
JavaScript
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));
|