Files
2026-08-14 15:56:39 -04:00

470 lines
17 KiB
JavaScript

#!/usr/bin/env node
import { open, readFile, readdir } from "node:fs/promises";
import path from "node:path";
import {
projectPath,
projectRoot,
readJson,
sourceRootFor,
validatePopulationRecipe,
} from "./lib/recipe.mjs";
const argv = process.argv.slice(2);
if (argv.includes("--help")) {
console.log("Usage: node scripts/runewaker-pipeline/audit-animation-readiness.mjs [--json]");
process.exit(0);
}
const jsonOutput = argv.includes("--json");
const recipeRoot = path.join(projectRoot, "scripts", "runewaker-pipeline", "recipes");
const GRANNY_GR2_MAGIC = "7203721b224e2aa09852d49b7b896b88";
const DYNAMIC_PROXY_REASON = /runtime\/dynamic display container.*no static triangle/i;
const rosCache = new Map();
const rasCache = new Map();
const glbCache = new Map();
function normalizeResource(value) {
return String(value ?? "")
.replaceAll("\\", "/")
.replace(/^\/+/, "")
.replace(/\s+\.(ros|ras)$/i, ".$1")
.toLowerCase();
}
function resourceFile(resourceRoot, resource) {
return path.join(resourceRoot, ...normalizeResource(resource).split("/"));
}
function printableStrings(buffer) {
const values = [];
let active = "";
for (const byte of buffer) {
if (byte >= 32 && byte <= 126) active += String.fromCharCode(byte);
else {
if (active.length >= 4) values.push(active);
active = "";
}
}
if (active.length >= 4) values.push(active);
return values;
}
function embeddedReferences(buffer, extension) {
const result = new Set();
for (const value of printableStrings(buffer)) {
const normalized = value.replaceAll("\\", "/");
const lower = normalized.toLowerCase();
const starts = ["model/", "motion/"]
.map((prefix) => lower.indexOf(prefix))
.filter((index) => index >= 0);
if (!starts.length) continue;
const start = Math.min(...starts);
const end = lower.indexOf("." + extension, start);
if (end < 0) continue;
result.add(normalizeResource(normalized.slice(start, end + extension.length + 1)));
}
return [...result].sort();
}
async function inspectRos(resourceRoot, resource) {
const normalized = normalizeResource(resource);
const key = resourceRoot + "|" + normalized;
if (!rosCache.has(key)) {
rosCache.set(key, (async () => {
try {
const bytes = await readFile(resourceFile(resourceRoot, normalized));
return {
exists: true,
ros: embeddedReferences(bytes, "ros"),
ras: embeddedReferences(bytes, "ras"),
};
} catch (error) {
if (error?.code === "ENOENT") return { exists: false, ros: [], ras: [] };
throw error;
}
})());
}
return rosCache.get(key);
}
async function inspectRas(resourceRoot, resource) {
const normalized = normalizeResource(resource);
const key = resourceRoot + "|" + normalized;
if (!rasCache.has(key)) {
rasCache.set(key, (async () => {
let handle;
try {
handle = await open(resourceFile(resourceRoot, normalized), "r");
const header = Buffer.alloc(64);
const { bytesRead } = await handle.read(header, 0, header.length, 0);
const bytes = header.subarray(0, bytesRead);
const gr2 = bytes.subarray(0, 16).toString("hex") === GRANNY_GR2_MAGIC
|| bytes.toString("latin1").includes("CRuAnimation_GR2");
return { exists: true, format: gr2 ? "gr2" : "legacy-or-unknown" };
} catch (error) {
if (error?.code === "ENOENT") return { exists: false, format: "missing" };
throw error;
} finally {
await handle?.close();
}
})());
}
return rasCache.get(key);
}
async function scanActorSource(resourceRoot, sourceModel) {
const queue = [normalizeResource(sourceModel)];
const visited = new Set();
const missingRos = [];
const ras = new Set();
while (queue.length) {
const resource = queue.shift();
if (!resource || visited.has(resource)) continue;
visited.add(resource);
const inspected = await inspectRos(resourceRoot, resource);
if (!inspected.exists) {
missingRos.push(resource);
continue;
}
for (const nested of inspected.ros) if (!visited.has(nested)) queue.push(nested);
for (const animation of inspected.ras) ras.add(animation);
}
const rasRows = [];
for (const resource of [...ras].sort()) {
rasRows.push({ resource, ...await inspectRas(resourceRoot, resource) });
}
return {
rosFiles: visited.size - missingRos.length,
missingRos,
rasReferences: rasRows.length,
gr2Ras: rasRows.filter((row) => row.format === "gr2").length,
missingRas: rasRows.filter((row) => !row.exists).map((row) => row.resource),
unknownRas: rasRows.filter((row) => row.exists && row.format !== "gr2").map((row) => row.resource),
};
}
async function inspectGlb(file) {
if (!glbCache.has(file)) {
glbCache.set(file, (async () => {
let handle;
try {
handle = await open(file, "r");
const header = Buffer.alloc(20);
const headerRead = await handle.read(header, 0, header.length, 0);
if (headerRead.bytesRead < 20 || header.toString("ascii", 0, 4) !== "glTF") {
throw new Error("not a GLB");
}
if (header.readUInt32LE(4) !== 2 || header.readUInt32LE(16) !== 0x4e4f534a) {
throw new Error("unsupported GLB header");
}
const jsonLength = header.readUInt32LE(12);
const jsonBytes = Buffer.alloc(jsonLength);
const jsonRead = await handle.read(jsonBytes, 0, jsonLength, 20);
if (jsonRead.bytesRead !== jsonLength) throw new Error("truncated GLB JSON");
const json = JSON.parse(jsonBytes.toString("utf8").replace(/\0+$/, "").trim());
const animations = (json.animations ?? []).map((animation, index) => ({
name: animation.name ?? "animation-" + index,
channels: animation.channels?.length ?? 0,
}));
return {
exists: true,
valid: true,
animations,
skins: json.skins?.length ?? 0,
skinnedNodes: (json.nodes ?? []).filter((node) => node.skin !== undefined).length,
skinAttributes: (json.meshes ?? []).some((mesh) => (
mesh.primitives?.some((primitive) => (
primitive.attributes?.JOINTS_0 !== undefined
&& primitive.attributes?.WEIGHTS_0 !== undefined
))
)),
};
} catch (error) {
if (error?.code === "ENOENT") {
return { exists: false, valid: false, error: "missing GLB", animations: [] };
}
return {
exists: true,
valid: false,
error: error instanceof Error ? error.message : String(error),
animations: [],
};
} finally {
await handle?.close();
}
})());
}
return glbCache.get(file);
}
function addIssue(row, severity, actorId, code, message) {
row.issues.push({ severity, actorId, code, message });
}
function sameNames(left, right) {
return [...left].sort().join("\n") === [...right].sort().join("\n");
}
const recipeFiles = (await readdir(recipeRoot))
.filter((file) => file.endsWith("-population.json"))
.sort();
const dungeons = [];
const unusedActors = [];
const auditedSourceRoots = new Set();
for (const recipeName of recipeFiles) {
const recipeFile = path.join(recipeRoot, recipeName);
const recipe = validatePopulationRecipe(await readJson(recipeFile), projectPath(recipeFile));
const resourceRoot = path.join(sourceRootFor(recipe), recipe.source.resourceRoot);
auditedSourceRoots.add(resourceRoot);
const row = {
dungeonId: recipe.dungeonId,
recipe: projectPath(recipeFile),
configuredActors: 0,
nativeActors: 0,
poseOnlyActors: 0,
dynamicProxies: 0,
unusedRecipeActors: 0,
sourceRosFiles: 0,
rasReferences: 0,
gr2Ras: 0,
packagedGlbs: 0,
issues: [],
actors: [],
};
const configuredIds = new Set(recipe.templates
.filter((template) => ["combat", "boss"].includes(template.classification))
.map((template) => template.actor)
.filter(Boolean));
row.configuredActors = configuredIds.size;
const recipeActors = new Map(recipe.actors.map((actor) => [actor.id, actor]));
let manifest;
try {
manifest = await readJson(path.resolve(projectRoot, recipe.files.actorManifest));
} catch (error) {
addIssue(
row,
configuredIds.size ? "blocker" : "warning",
null,
"manifest-unreadable",
error instanceof Error ? error.message : String(error),
);
manifest = { status: "missing", assets: [], proceduralFallbacks: [] };
}
if (manifest.status !== "green") {
addIssue(row, configuredIds.size ? "blocker" : "warning", null, "manifest-status", "Manifest is not green.");
}
const assetGroups = new Map();
for (const asset of manifest.assets ?? []) {
const baseActorId = asset.baseActorId ?? asset.id;
const group = assetGroups.get(baseActorId) ?? [];
group.push(asset);
assetGroups.set(baseActorId, group);
}
const fallbacks = new Map((manifest.proceduralFallbacks ?? []).map((fallback) => [fallback.id, fallback]));
for (const [actorId, actor] of recipeActors) {
const configured = configuredIds.has(actorId);
if (!configured) {
row.unusedRecipeActors += 1;
const packagedAs = assetGroups.has(actorId) ? "native-package" : fallbacks.has(actorId) ? "dynamic-proxy" : "missing";
unusedActors.push({ dungeonId: recipe.dungeonId, actorId, packagedAs });
}
const source = await scanActorSource(resourceRoot, actor.sourceModel);
if (configured) {
row.sourceRosFiles += source.rosFiles;
row.rasReferences += source.rasReferences;
row.gr2Ras += source.gr2Ras;
}
const detail = {
id: actorId,
configured,
sourceModel: actor.sourceModel,
source,
package: null,
};
row.actors.push(detail);
const issueSeverity = configured ? "blocker" : "warning";
const rootSource = normalizeResource(actor.sourceModel);
if (source.missingRos.includes(rootSource)) {
addIssue(row, issueSeverity, actorId, "source-ros-missing", rootSource);
}
const missingNestedRos = source.missingRos.filter((resource) => resource !== rootSource);
if (missingNestedRos.length) {
addIssue(row, "warning", actorId, "source-ros-reference-missing", missingNestedRos.join(", "));
}
if (source.missingRas.length) {
addIssue(row, "warning", actorId, "source-ras-missing", source.missingRas.join(", "));
}
if (source.unknownRas.length) {
addIssue(row, "warning", actorId, "source-ras-not-gr2", source.unknownRas.join(", "));
}
const actorAssets = assetGroups.get(actorId) ?? [];
const asset = actorAssets[0];
const fallback = fallbacks.get(actorId);
if (asset && fallback) {
addIssue(row, issueSeverity, actorId, "package-ambiguous", "Actor is both an asset and a proxy fallback.");
continue;
}
if (!asset && !fallback) {
addIssue(row, issueSeverity, actorId, "package-missing", "Actor is absent from manifest assets and fallbacks.");
continue;
}
if (fallback) {
detail.package = { mode: "dynamic-proxy", reason: fallback.reason };
if (configured) row.dynamicProxies += 1;
if (fallback.sourceModel !== actor.sourceModel) {
addIssue(row, issueSeverity, actorId, "proxy-source-mismatch", "Proxy sourceModel differs from its recipe.");
}
if (!DYNAMIC_PROXY_REASON.test(String(fallback.reason ?? ""))) {
addIssue(row, issueSeverity, actorId, "proxy-undocumented", "Proxy needs the documented dynamic/no-triangle reason.");
}
continue;
}
row.packagedGlbs += configured ? actorAssets.length : 0;
const poseException = actor.animationException?.kind === "pose-only";
const glbFile = path.resolve(projectRoot, "public", String(asset.url ?? "").replace(/^\/+/, ""));
const glb = await inspectGlb(glbFile);
detail.package = {
mode: poseException ? "pose-only" : "native",
variants: actorAssets.length,
manifestAnimations: asset.animations?.length ?? 0,
glbAnimations: glb.animations.length,
glb: projectPath(glbFile),
};
if (!glb.exists || !glb.valid) {
addIssue(row, issueSeverity, actorId, "glb-invalid", glb.error ?? "Invalid GLB.");
continue;
}
const manifestNames = asset.animations ?? [];
const glbNames = glb.animations.map((animation) => animation.name);
if (!sameNames(manifestNames, glbNames)) {
addIssue(row, issueSeverity, actorId, "clip-manifest-mismatch", "Manifest and GLB clip names differ.");
}
if (poseException) {
if (configured) row.poseOnlyActors += 1;
if (!actor.animationException.reason?.trim()) {
addIssue(row, issueSeverity, actorId, "pose-undocumented", "Pose-only exception needs a reason.");
}
if (manifestNames.length || glbNames.length) {
addIssue(row, issueSeverity, actorId, "pose-has-clips", "Pose-only actor unexpectedly contains clips.");
}
continue;
}
if (configured) row.nativeActors += 1;
if (!manifestNames.length || !glbNames.length) {
addIssue(row, issueSeverity, actorId, "native-clips-missing", "Direct actor has no native clips.");
}
if (!glb.skins || !glb.skinnedNodes || !glb.skinAttributes) {
addIssue(row, issueSeverity, actorId, "native-skin-missing", "Direct actor lacks a complete GLB skin binding.");
}
const emptyClips = glb.animations.filter((animation) => animation.channels === 0).map((animation) => animation.name);
if (emptyClips.length) {
addIssue(row, issueSeverity, actorId, "native-empty-clips", emptyClips.join(", "));
}
for (const semantic of ["Attack", "Wound", "Death"]) {
if (!glbNames.some((name) => name.startsWith(semantic + " - "))) {
addIssue(row, issueSeverity, actorId, "native-semantic-missing", "Missing " + semantic + " clip family.");
}
}
if (!glbNames.some((name) => /^(Stand|Idle) - /.test(name))) {
addIssue(row, issueSeverity, actorId, "native-semantic-missing", "Missing stand/idle clip family.");
}
if (configured && source.gr2Ras === 0) {
addIssue(row, "warning", actorId, "source-gr2-unresolved", "No GR2 RAS was found through the source ROS graph.");
}
}
for (const actorId of configuredIds) {
if (!recipeActors.has(actorId)) {
addIssue(row, "blocker", actorId, "recipe-actor-missing", "Combat template references an absent actor.");
}
}
for (const actorId of new Set([...assetGroups.keys(), ...fallbacks.keys()])) {
if (!recipeActors.has(actorId)) {
addIssue(row, "warning", actorId, "manifest-orphan", "Manifest entry is not declared by the recipe.");
}
}
row.actors.sort((left, right) => left.id.localeCompare(right.id));
row.status = row.issues.some((issue) => issue.severity === "blocker") ? "FAIL" : "PASS";
dungeons.push(row);
}
const totals = dungeons.reduce((sum, row) => {
for (const key of [
"configuredActors", "nativeActors", "poseOnlyActors", "dynamicProxies",
"unusedRecipeActors", "sourceRosFiles", "rasReferences", "gr2Ras", "packagedGlbs",
]) sum[key] += row[key];
sum.blockers += row.issues.filter((issue) => issue.severity === "blocker").length;
sum.warnings += row.issues.filter((issue) => issue.severity === "warning").length;
return sum;
}, {
dungeons: dungeons.length,
configuredActors: 0,
nativeActors: 0,
poseOnlyActors: 0,
dynamicProxies: 0,
unusedRecipeActors: 0,
sourceRosFiles: 0,
rasReferences: 0,
gr2Ras: 0,
packagedGlbs: 0,
blockers: 0,
warnings: 0,
});
const report = {
schemaVersion: 1,
status: totals.blockers ? "fail" : "pass",
sourceRoots: [...auditedSourceRoots],
totals,
unusedActors,
dungeons,
};
function markdown() {
const lines = [
"# RuneWaker all-dungeon animation readiness",
"",
"Status: **" + report.status.toUpperCase() + "**",
"",
"| Dungeon | Configured | Native | Pose | Dynamic proxy | Unused | ROS | RAS | GR2 RAS | GLBs | Result |",
"|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|",
];
for (const row of dungeons) {
lines.push("| " + row.dungeonId + " | " + [
row.configuredActors, row.nativeActors, row.poseOnlyActors, row.dynamicProxies,
row.unusedRecipeActors, row.sourceRosFiles, row.rasReferences, row.gr2Ras,
row.packagedGlbs, row.status,
].join(" | ") + " |");
}
lines.push(
"",
"Configured contract: **" + totals.configuredActors + "** actors = **"
+ totals.nativeActors + " native + " + totals.poseOnlyActors + " pose-only + "
+ totals.dynamicProxies + " dynamic proxies**.",
"",
"Unused recipe actors (reported outside the combat contract): **"
+ totals.unusedRecipeActors + "**.",
);
if (unusedActors.length) {
lines.push("", "## Unused recipe actors", "");
for (const actor of unusedActors) {
lines.push("- " + actor.dungeonId + ":" + actor.actorId + " (" + actor.packagedAs + ")");
}
}
const issues = dungeons.flatMap((row) => row.issues.map((issue) => ({ dungeonId: row.dungeonId, ...issue })));
for (const severity of ["blocker", "warning"]) {
const selected = issues.filter((issue) => issue.severity === severity);
if (!selected.length) continue;
lines.push("", "## " + (severity === "blocker" ? "Blockers" : "Warnings"), "");
for (const issue of selected) {
lines.push("- " + issue.dungeonId + (issue.actorId ? ":" + issue.actorId : "")
+ " [" + issue.code + "] " + issue.message);
}
}
return lines.join("\n") + "\n";
}
process.stdout.write(jsonOutput ? JSON.stringify(report, null, 2) + "\n" : markdown());
if (totals.blockers) process.exitCode = 1;