470 lines
17 KiB
JavaScript
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;
|