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