Files
healer-man/scripts/runewaker-pipeline/inventory-animation-assets.mjs
T
2026-08-14 15:56:39 -04:00

789 lines
30 KiB
JavaScript

#!/usr/bin/env node
import { execFile } from "node:child_process";
import { open, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import {
projectPath,
projectRoot,
readJson,
sourceRootFor,
validatePopulationRecipe,
} from "./lib/recipe.mjs";
const recipeRoot = path.join(projectRoot, "scripts", "runewaker-pipeline", "recipes");
const outputRoot = path.join(projectRoot, "artifacts", "animation-audit");
const jsonFile = path.join(outputRoot, "inventory.json");
const markdownFile = path.join(outputRoot, "inventory.md");
const copyZones = new Map([
["demon-stronghold-125", "demon-stronghold"],
["zurhidon-stronghold-124", "zurhidon-stronghold"],
]);
const expected = {
dungeons: 30,
entityTemplates: 436,
nativeEntityMappings: 435,
poseOnlyEntityMappings: 1,
actorPackages: 268,
shippingAssetVariants: 322,
emptyCopyZones: 2,
};
const glbCache = new Map();
const execFileAsync = promisify(execFile);
function constantCase(value) {
return String(value).replaceAll("-", "_").toUpperCase();
}
/* Superseded malformed separator literal retained only as inert text.
function normalizeResource(value) {
return String(value ?? "").replaceAll("\", "/").replace(/^\/+/, "").toLowerCase();
}
*/
function normalizeResource(value) {
const separator = String.fromCharCode(92);
return String(value ?? "").replaceAll(separator, "/").replace(/^[/]+/, "").toLowerCase();
}
function resourceFile(resourceRoot, resource) {
return path.join(resourceRoot, ...normalizeResource(resource).split("/"));
}
function generatedJson(source, name, suffix) {
const marker = "export const " + name + " = ";
const start = source.indexOf(marker);
if (start < 0) throw new Error("Missing generated constant " + name + ".");
const valueStart = start + marker.length;
const valueEnd = source.indexOf(suffix, valueStart);
if (valueEnd < 0) throw new Error("Missing generated suffix for " + name + ".");
return JSON.parse(source.slice(valueStart, valueEnd));
}
async function fileInfo(file) {
try {
const value = await stat(file);
return { exists: value.isFile(), size: value.isFile() ? value.size : null };
} catch (error) {
if (error?.code === "ENOENT") return { exists: false, size: null };
throw error;
}
}
async function convertedRawCandidates(directory) {
try {
return (await readdir(directory, { withFileTypes: true }))
.filter((entry) => entry.isFile() && /\.raw\.glb$/i.test(entry.name))
.map((entry) => projectPath(path.join(directory, entry.name)))
.sort();
} catch (error) {
if (error?.code === "ENOENT") return [];
throw error;
}
}
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 document = JSON.parse(jsonBytes.toString("utf8").replace(/\0+$/, "").trim());
return {
exists: true,
valid: true,
animationNames: (document.animations ?? [])
.map((animation, index) => animation.name ?? "animation-" + index)
.sort(),
};
} catch (error) {
if (error?.code === "ENOENT") {
return { exists: false, valid: false, error: "missing GLB", animationNames: [] };
}
return {
exists: true,
valid: false,
error: error instanceof Error ? error.message : String(error),
animationNames: [],
};
} finally {
await handle?.close();
}
})());
}
return glbCache.get(file);
}
function semanticFamily(name) {
const prefix = String(name).split(" - ", 1)[0].trim().toLowerCase();
if (prefix === "stand" || prefix === "idle") return "idle";
if (prefix === "walk" || prefix === "run") return "move";
if (prefix === "attack") return "attack";
if (prefix === "cast") return "cast";
if (prefix === "wound" || prefix === "hit") return "wound";
if (prefix === "death" || prefix === "dead" || prefix === "terminal pose") return "death";
if (prefix === "action") return "action";
return "other";
}
function semanticClips(names) {
const result = {};
for (const name of [...names].sort()) {
const family = semanticFamily(name);
if (!result[family]) result[family] = [];
result[family].push(name);
}
return Object.fromEntries(Object.entries(result).sort(([left], [right]) => left.localeCompare(right)));
}
function sameValues(left, right) {
return [...left].sort().join("\n") === [...right].sort().join("\n");
}
function markdownSafe(value) {
return String(value ?? "").replaceAll("|", "\\|").replaceAll("\n", " ");
}
function countBy(rows, key) {
const result = {};
for (const row of rows) result[row[key]] = (result[row[key]] ?? 0) + 1;
return Object.fromEntries(Object.entries(result).sort(([left], [right]) => left.localeCompare(right)));
}
const findings = [];
function addFinding(severity, scope, key, code, message, paths = []) {
const finding = { severity, scope, key, code, message, paths: [...paths].sort() };
findings.push(finding);
return code;
}
const recipeNames = (await readdir(recipeRoot))
.filter((name) => name.endsWith("-population.json"))
.sort();
const dungeons = [];
const actorPackages = [];
const shippingAssetVariants = [];
const entityTemplates = [];
const unusedRecipeActors = [];
let allManifestAssetVariants = 0;
let unusedManifestAssetVariants = 0;
const readinessScript = path.join(
projectRoot,
"scripts",
"runewaker-pipeline",
"audit-animation-readiness.mjs",
);
let readinessOutput;
try {
readinessOutput = await execFileAsync(process.execPath, [readinessScript, "--json"], {
cwd: projectRoot,
windowsHide: true,
maxBuffer: 64 * 1024 * 1024,
});
} catch (error) {
if (!error?.stdout) throw error;
readinessOutput = { stdout: error.stdout };
}
const readiness = JSON.parse(readinessOutput.stdout);
const readinessActors = new Map();
const readinessIssues = new Map();
for (const dungeon of readiness.dungeons ?? []) {
for (const actor of dungeon.actors ?? []) {
readinessActors.set(dungeon.dungeonId + ":" + actor.id, actor);
}
for (const sourceIssue of dungeon.issues ?? []) {
if (!sourceIssue.actorId) continue;
const key = dungeon.dungeonId + ":" + sourceIssue.actorId;
const rows = readinessIssues.get(key) ?? [];
rows.push(sourceIssue);
readinessIssues.set(key, rows);
}
}
for (const recipeName of recipeNames) {
const recipeFile = path.join(recipeRoot, recipeName);
const recipe = validatePopulationRecipe(await readJson(recipeFile), projectPath(recipeFile));
const [manifest, generatedSource] = await Promise.all([
readJson(path.resolve(projectRoot, recipe.files.actorManifest)),
readFile(path.resolve(projectRoot, recipe.files.generatedSource), "utf8"),
]);
const entities = generatedJson(
generatedSource,
constantCase(recipe.dungeonId) + "_ENTITIES",
" as const satisfies PopulationDefinitionMap;",
);
const combatTemplates = recipe.templates
.filter((template) => ["combat", "boss"].includes(template.classification))
.sort((left, right) => Number(left.id) - Number(right.id) || left.name.localeCompare(right.name));
const configuredActorIds = new Set(combatTemplates.map((template) => template.actor));
const actorsById = new Map(recipe.actors.map((actor) => [actor.id, actor]));
const manifestGroups = new Map();
for (const asset of manifest.assets ?? []) {
allManifestAssetVariants += 1;
const baseActorId = asset.baseActorId ?? asset.id;
const group = manifestGroups.get(baseActorId) ?? [];
group.push(asset);
manifestGroups.set(baseActorId, group);
if (!configuredActorIds.has(baseActorId)) unusedManifestAssetVariants += 1;
}
for (const group of manifestGroups.values()) group.sort((left, right) => left.id.localeCompare(right.id));
const fallbackById = new Map(
(manifest.proceduralFallbacks ?? []).map((fallback) => [fallback.id, fallback]),
);
const resourceRoot = path.join(sourceRootFor(recipe), recipe.source.resourceRoot);
const actorPackageById = new Map();
const assetVariantByUrl = new Map();
for (const actor of recipe.actors) {
if (!configuredActorIds.has(actor.id)) {
unusedRecipeActors.push({
dungeonId: recipe.dungeonId,
actorId: actor.id,
sourceRos: normalizeResource(actor.sourceModel),
manifestAssetVariants: (manifestGroups.get(actor.id) ?? []).map((asset) => asset.id),
proceduralFallback: fallbackById.has(actor.id),
});
}
}
for (const actorId of [...configuredActorIds].sort()) {
const packageKey = recipe.dungeonId + ":" + actorId;
const actor = actorsById.get(actorId);
const readinessActor = readinessActors.get(packageKey) ?? null;
const sourceGraphIssues = readinessIssues.get(packageKey) ?? [];
const assets = manifestGroups.get(actorId) ?? [];
const fallback = fallbackById.get(actorId) ?? null;
const localFindingCodes = [];
if (!actor) {
localFindingCodes.push(addFinding(
"blocker", "actor-package", packageKey, "recipe-actor-missing",
"Configured templates reference an actor absent from the recipe.",
));
}
if (assets.length && fallback) {
localFindingCodes.push(addFinding(
"blocker", "actor-package", packageKey, "manifest-path-ambiguous",
"Actor is represented by both shipping GLB assets and a procedural fallback.",
));
}
if (!assets.length && !fallback) {
localFindingCodes.push(addFinding(
"blocker", "actor-package", packageKey, "manifest-path-missing",
"Actor has neither shipping GLB assets nor a procedural fallback.",
));
}
const sourceRos = normalizeResource(actor?.sourceModel);
const sourceFile = actor ? resourceFile(resourceRoot, sourceRos) : null;
const sourceInfo = sourceFile ? await fileInfo(sourceFile) : { exists: false, size: null };
if (!sourceInfo.exists) {
localFindingCodes.push(addFinding(
"warning", "actor-package", packageKey, "source-ros-missing",
"The configured source ROS is absent from the preserved resource tree.",
sourceFile ? [projectPath(sourceFile)] : [],
));
}
for (const sourceIssue of sourceGraphIssues) {
localFindingCodes.push(addFinding(
sourceIssue.severity,
"source-animation-graph",
packageKey,
sourceIssue.code,
sourceIssue.message,
[sourceIssue.message],
));
}
const animationMode = fallback
? "dynamic-proxy"
: actor?.animationException?.kind === "pose-only"
? "pose-only"
: "native";
const packageRow = {
key: packageKey,
dungeonId: recipe.dungeonId,
actorId,
animationMode,
sourceRos: {
resource: sourceRos || null,
resolvedPath: sourceFile ? projectPath(sourceFile) : null,
exists: sourceInfo.exists,
size: sourceInfo.size,
},
sourceAnimationGraph: readinessActor?.source ?? null,
shippingAssetVariantKeys: [],
templateEntityKeys: [],
fallback: fallback ? {
sourceModel: fallback.sourceModel ?? null,
reason: fallback.reason ?? null,
} : null,
findingCodes: localFindingCodes,
};
actorPackages.push(packageRow);
actorPackageById.set(actorId, packageRow);
for (const asset of assets) {
const assetKey = recipe.dungeonId + ":" + asset.id;
const shippingFile = path.resolve(
projectRoot,
"public",
String(asset.url ?? "").replace(/^\/+/, ""),
);
const convertedDirectory = path.join(
projectRoot,
"runewaker-export-work",
recipe.dungeonId + "-population",
"actors",
asset.id,
"converted",
);
const rawAnimatedFile = path.join(convertedDirectory, asset.id + ".animated.raw.glb");
const rawStaticFile = path.join(convertedDirectory, asset.id + ".raw.glb");
const [shippingInfo, shippingStat, rawAnimatedInfo, rawStaticInfo, rawCandidates] = await Promise.all([
inspectGlb(shippingFile),
fileInfo(shippingFile),
fileInfo(rawAnimatedFile),
fileInfo(rawStaticFile),
convertedRawCandidates(convertedDirectory),
]);
const manifestClipNames = [...(asset.animations ?? [])].sort();
const actualClipNames = shippingInfo.animationNames;
const localAssetFindings = [];
if (!asset.url) {
localAssetFindings.push(addFinding(
"blocker", "shipping-asset", assetKey, "shipping-url-missing",
"Configured manifest asset has no authoritative shipping URL.",
));
}
if (!shippingInfo.exists || !shippingInfo.valid) {
localAssetFindings.push(addFinding(
"blocker", "shipping-asset", assetKey, "shipping-glb-missing-or-invalid",
shippingInfo.error ?? "Shipping GLB is missing or invalid.",
[projectPath(shippingFile)],
));
}
if (shippingInfo.valid && !sameValues(manifestClipNames, actualClipNames)) {
localAssetFindings.push(addFinding(
"blocker", "shipping-asset", assetKey, "shipping-clip-manifest-mismatch",
"Shipping GLB animation names differ from its authoritative manifest.",
[projectPath(shippingFile)],
));
}
if (!rawAnimatedInfo.exists) {
localAssetFindings.push(addFinding(
"warning", "shipping-asset", assetKey, "raw-animation-cache-missing",
"The expected uncompressed animated raw GLB is absent; shipping manifest URL remains authoritative.",
[projectPath(rawAnimatedFile), ...rawCandidates],
));
}
if (asset.sourceModel && normalizeResource(asset.sourceModel) !== sourceRos) {
localAssetFindings.push(addFinding(
"warning", "shipping-asset", assetKey, "source-ros-ambiguous",
"Manifest asset sourceModel differs from the configured base actor source ROS.",
[sourceRos, normalizeResource(asset.sourceModel)],
));
}
const clips = semanticClips(actualClipNames);
const assetRow = {
key: assetKey,
dungeonId: recipe.dungeonId,
actorPackageKey: packageKey,
actorId,
assetId: asset.id,
baseActorId: asset.baseActorId ?? asset.id,
sourceImageId: asset.sourceImageId ?? null,
animationMode,
sourceRos: normalizeResource(asset.sourceModel ?? actor?.sourceModel) || null,
shippingGlb: {
url: asset.url ?? null,
path: projectPath(shippingFile),
exists: shippingInfo.exists,
valid: shippingInfo.valid,
size: shippingStat.size,
},
rebuiltAnimatedRawGlb: {
expectedPath: projectPath(rawAnimatedFile),
exists: rawAnimatedInfo.exists,
size: rawAnimatedInfo.size,
cacheCandidates: rawCandidates,
},
rebuiltStaticRawGlb: {
path: projectPath(rawStaticFile),
exists: rawStaticInfo.exists,
size: rawStaticInfo.size,
},
semanticClipFamilies: Object.keys(clips),
semanticClips: clips,
clipCount: actualClipNames.length,
manifestClipCount: manifestClipNames.length,
templateEntityKeys: [],
findingCodes: localAssetFindings,
};
shippingAssetVariants.push(assetRow);
packageRow.shippingAssetVariantKeys.push(assetKey);
if (asset.url) assetVariantByUrl.set(asset.url, assetRow);
}
}
for (const template of combatTemplates) {
const entityId = recipe.runtimeIdPrefix + "-" + template.id;
const entityKey = recipe.dungeonId + ":" + template.id;
const entity = entities[entityId];
const actorPackage = actorPackageById.get(template.actor);
const shippingUrl = entity?.visual?.model?.url ?? null;
const asset = shippingUrl ? assetVariantByUrl.get(shippingUrl) : null;
const localFindingCodes = [];
if (!entity) {
localFindingCodes.push(addFinding(
"blocker", "entity-template", entityKey, "generated-entity-missing",
"Configured combat template has no generated entity.",
));
}
if (entity && entity.name !== template.name) {
localFindingCodes.push(addFinding(
"blocker", "entity-template", entityKey, "generated-entity-name-mismatch",
"Generated entity name differs from the configured template name.",
));
}
if (shippingUrl && !asset) {
localFindingCodes.push(addFinding(
"blocker", "entity-template", entityKey, "entity-shipping-path-ambiguous",
"Generated entity shipping URL does not resolve to its configured manifest actor group.",
[shippingUrl],
));
}
if (!shippingUrl && actorPackage?.animationMode !== "dynamic-proxy") {
localFindingCodes.push(addFinding(
"blocker", "entity-template", entityKey, "entity-shipping-path-missing",
"Generated entity has no shipping GLB URL and is not a documented dynamic proxy.",
));
}
if (normalizeResource(template.source?.modelPath) && actorPackage?.sourceRos.resource
&& normalizeResource(template.source.modelPath) !== actorPackage.sourceRos.resource) {
localFindingCodes.push(addFinding(
"warning", "entity-template", entityKey, "template-source-ros-ambiguous",
"Template source modelPath differs from its configured actor source ROS.",
[normalizeResource(template.source.modelPath), actorPackage.sourceRos.resource],
));
}
const animationMode = actorPackage?.animationMode ?? "missing";
const record = {
key: entityKey,
dungeonId: recipe.dungeonId,
templateId: Number(template.id),
entityId,
templateName: template.name,
entityName: entity?.name ?? null,
classification: template.classification,
runtimeKind: entity?.kind ?? null,
actorId: template.actor,
actorPackageKey: actorPackage?.key ?? null,
actorSourceRos: actorPackage?.sourceRos.resource ?? null,
templateSourceRos: normalizeResource(template.source?.modelPath) || null,
shippingAssetKey: asset?.key ?? null,
shippingGlbUrl: shippingUrl,
shippingGlbPath: asset?.shippingGlb.path ?? null,
rebuiltAnimatedRawGlbPath: asset?.rebuiltAnimatedRawGlb.expectedPath ?? null,
rebuiltAnimatedRawGlbExists: asset?.rebuiltAnimatedRawGlb.exists ?? false,
animationMode,
runtimeAnimationMode: entity?.visual?.model?.animationMode
?? (animationMode === "dynamic-proxy" ? "procedural-proxy" : null),
semanticClipFamilies: asset?.semanticClipFamilies ?? [],
clipCount: asset?.clipCount ?? 0,
findingCodes: [
...(actorPackage?.findingCodes ?? []),
...(asset?.findingCodes ?? []),
...localFindingCodes,
].filter((code, index, values) => values.indexOf(code) === index).sort(),
};
entityTemplates.push(record);
if (actorPackage) actorPackage.templateEntityKeys.push(entityKey);
if (asset) asset.templateEntityKeys.push(entityKey);
}
const emptyCopyZoneOf = copyZones.get(recipe.dungeonId) ?? null;
dungeons.push({
dungeonId: recipe.dungeonId,
zoneId: recipe.zoneId,
recipe: projectPath(recipeFile),
entityTemplates: combatTemplates.length,
actorPackages: configuredActorIds.size,
shippingAssetVariants: [...configuredActorIds]
.reduce((sum, actorId) => sum + (manifestGroups.get(actorId)?.length ?? 0), 0),
manifestAssetVariants: manifest.assets?.length ?? 0,
emptyCopyZone: Boolean(emptyCopyZoneOf),
copiesEnvironmentFrom: emptyCopyZoneOf,
status: emptyCopyZoneOf ? "authoritative-empty-copy-zone" : "configured",
});
}
for (const asset of shippingAssetVariants) {
asset.templateEntityKeys.sort();
if (!asset.templateEntityKeys.length) {
asset.findingCodes.push(addFinding(
"warning", "shipping-asset", asset.key, "configured-variant-unreferenced",
"Configured actor package variant is not referenced by a generated combat entity.",
[asset.shippingGlb.url ?? asset.shippingGlb.path],
));
asset.findingCodes.sort();
}
}
for (const actor of actorPackages) {
actor.shippingAssetVariantKeys.sort();
actor.templateEntityKeys.sort();
actor.findingCodes.sort();
}
dungeons.sort((left, right) => left.dungeonId.localeCompare(right.dungeonId));
actorPackages.sort((left, right) => left.key.localeCompare(right.key));
shippingAssetVariants.sort((left, right) => left.key.localeCompare(right.key));
entityTemplates.sort((left, right) => (
left.dungeonId.localeCompare(right.dungeonId)
|| left.templateId - right.templateId
|| left.key.localeCompare(right.key)
));
unusedRecipeActors.sort((left, right) => (
left.dungeonId.localeCompare(right.dungeonId) || left.actorId.localeCompare(right.actorId)
));
const summary = {
dungeons: dungeons.length,
entityTemplates: entityTemplates.length,
entityAnimationModes: countBy(entityTemplates, "animationMode"),
actorPackages: actorPackages.length,
actorPackageAnimationModes: countBy(actorPackages, "animationMode"),
shippingAssetVariants: shippingAssetVariants.length,
shippingAssetAnimationModes: countBy(shippingAssetVariants, "animationMode"),
runtimeReferencedShippingAssetVariants: shippingAssetVariants
.filter((asset) => asset.templateEntityKeys.length).length,
allManifestAssetVariants,
unusedManifestAssetVariants,
unusedRecipeActors: unusedRecipeActors.length,
uniqueActorIdsAcrossDungeons: new Set(actorPackages.map((actor) => actor.actorId)).size,
uniqueSourceRos: new Set(actorPackages.map((actor) => actor.sourceRos.resource).filter(Boolean)).size,
uniqueShippingGlbUrls: new Set(
shippingAssetVariants.map((asset) => asset.shippingGlb.url).filter(Boolean),
).size,
rebuiltAnimatedRawGlbsPresent: shippingAssetVariants
.filter((asset) => asset.rebuiltAnimatedRawGlb.exists).length,
rebuiltAnimatedRawGlbsMissing: shippingAssetVariants
.filter((asset) => !asset.rebuiltAnimatedRawGlb.exists).length,
sourceAnimationGraph: {
rosFiles: readiness.totals?.sourceRosFiles ?? 0,
rasReferences: readiness.totals?.rasReferences ?? 0,
gr2Ras: readiness.totals?.gr2Ras ?? 0,
warnings: findings.filter((finding) => (
finding.scope === "source-animation-graph" && finding.severity === "warning"
)).length,
},
emptyCopyZones: dungeons.filter((dungeon) => dungeon.emptyCopyZone).length,
findings: {
blockers: findings.filter((finding) => finding.severity === "blocker").length,
warnings: findings.filter((finding) => finding.severity === "warning").length,
},
};
for (const [key, expectedValue] of Object.entries(expected)) {
const observed = key === "nativeEntityMappings"
? summary.entityAnimationModes.native ?? 0
: key === "poseOnlyEntityMappings"
? summary.entityAnimationModes["pose-only"] ?? 0
: summary[key];
if (observed !== expectedValue) {
addFinding(
"blocker", "inventory", "summary", "contract-count-mismatch",
key + " expected " + expectedValue + " but observed " + observed + ".",
);
}
}
summary.findings = {
blockers: findings.filter((finding) => finding.severity === "blocker").length,
warnings: findings.filter((finding) => finding.severity === "warning").length,
};
const poseOnlyMappings = entityTemplates
.filter((entry) => entry.animationMode === "pose-only")
.map((entry) => ({
dungeonId: entry.dungeonId,
templateId: entry.templateId,
entityId: entry.entityId,
name: entry.templateName,
actorId: entry.actorId,
shippingGlbUrl: entry.shippingGlbUrl,
}));
const report = {
schemaVersion: 1,
status: summary.findings.blockers ? "blocked" : "green",
command: "npm run runewaker:audit:animation-inventory",
scope: "All configured combat and boss templates in 30 RuneWaker population recipes",
authority: {
shippingPath: "actor manifest URL",
sourceActor: "population recipe actor sourceModel",
runtimeBinding: "generated entity visual.model",
rebuiltRawPath: "deterministic conversion work path; absence may mean cache not retained",
sourceAnimationGraph: "read-only traversal from audit-animation-readiness.mjs",
},
expectedContract: expected,
summary,
poseOnlyMappings,
emptyCopyZones: dungeons.filter((dungeon) => dungeon.emptyCopyZone),
dungeons,
actorPackages,
shippingAssetVariants,
entityTemplates,
unusedRecipeActors,
findings,
};
function renderMarkdown() {
const lines = [
"# RuneWaker combat actor and animation inventory",
"",
"Status: **" + report.status.toUpperCase() + "**",
"",
"Reproduce with: npm run runewaker:audit:animation-inventory",
"",
"Shipping manifest URLs are authoritative. Rebuilt .animated.raw.glb paths are",
"development-cache evidence and may be absent without making the shipped GLB invalid.",
"",
"## Accounting",
"",
"- " + summary.entityTemplates + " combat/boss template-entity definitions: "
+ (summary.entityAnimationModes.native ?? 0) + " native and "
+ (summary.entityAnimationModes["pose-only"] ?? 0) + " pose-only.",
"- " + summary.actorPackages + " configured actor packages expand to "
+ summary.shippingAssetVariants + " configured shipping GLB variants.",
"- " + summary.runtimeReferencedShippingAssetVariants + " variants are referenced by generated entities; "
+ summary.allManifestAssetVariants + " assets exist across the full manifests.",
"- " + summary.rebuiltAnimatedRawGlbsPresent + " rebuilt animated raw GLBs are present; "
+ summary.rebuiltAnimatedRawGlbsMissing + " expected work-cache paths are absent.",
"- Source traversal found " + summary.sourceAnimationGraph.rosFiles + " ROS files and "
+ summary.sourceAnimationGraph.rasReferences + " RAS references, including "
+ summary.sourceAnimationGraph.gr2Ras + " GR2-backed references and "
+ summary.sourceAnimationGraph.warnings + " source-graph warnings.",
"- " + summary.uniqueActorIdsAcrossDungeons + " global actor IDs, "
+ summary.uniqueSourceRos + " unique source ROS resources, and "
+ summary.uniqueShippingGlbUrls + " unique shipping URLs.",
"",
"Pose-only mapping: " + poseOnlyMappings.map((entry) => (
entry.dungeonId + " template " + entry.templateId + " " + entry.name
+ " via " + entry.actorId
)).join("; "),
"",
"## Dungeon accounting",
"",
"| Dungeon | Zone | Entities | Actor packages | GLB variants | Manifest variants | Status |",
"|---|---:|---:|---:|---:|---:|---|",
];
for (const dungeon of dungeons) {
lines.push("| " + [
dungeon.dungeonId,
dungeon.zoneId,
dungeon.entityTemplates,
dungeon.actorPackages,
dungeon.shippingAssetVariants,
dungeon.manifestAssetVariants,
dungeon.status,
].map(markdownSafe).join(" | ") + " |");
}
lines.push(
"",
"## Configured actor packages",
"",
"| Actor package | Mode | Source ROS | Variants | Entities | Findings |",
"|---|---|---|---:|---:|---|",
);
for (const actor of actorPackages) {
lines.push("| " + [
actor.key,
actor.animationMode,
actor.sourceRos.resource,
actor.shippingAssetVariantKeys.length,
actor.templateEntityKeys.length,
actor.findingCodes.join(", ") || "none",
].map(markdownSafe).join(" | ") + " |");
}
lines.push(
"",
"## Configured shipping GLB variants",
"",
"| Asset | Mode | Shipping GLB | Animated raw rebuild | Families | Entity refs | Findings |",
"|---|---|---|---|---|---:|---|",
);
for (const asset of shippingAssetVariants) {
lines.push("| " + [
asset.key,
asset.animationMode,
asset.shippingGlb.url,
asset.rebuiltAnimatedRawGlb.expectedPath
+ (asset.rebuiltAnimatedRawGlb.exists ? " (present)" : " (cache missing)"),
asset.semanticClipFamilies.join(", "),
asset.templateEntityKeys.length,
asset.findingCodes.join(", ") || "none",
].map(markdownSafe).join(" | ") + " |");
}
lines.push(
"",
"## Combat and boss template-entity definitions",
"",
"| Dungeon | Template | Entity | Name | Class | Actor | Mode | Shipping GLB | Animated raw | Families | Findings |",
"|---|---:|---|---|---|---|---|---|---|---|---|",
);
for (const entity of entityTemplates) {
lines.push("| " + [
entity.dungeonId,
entity.templateId,
entity.entityId,
entity.templateName,
entity.classification,
entity.actorId,
entity.animationMode,
entity.shippingGlbUrl ?? "none",
entity.rebuiltAnimatedRawGlbPath
? entity.rebuiltAnimatedRawGlbPath
+ (entity.rebuiltAnimatedRawGlbExists ? " (present)" : " (cache missing)")
: "none",
entity.semanticClipFamilies.join(", "),
entity.findingCodes.join(", ") || "none",
].map(markdownSafe).join(" | ") + " |");
}
lines.push("", "## Missing or ambiguous paths", "");
const pathFindings = findings.filter((finding) => /path|missing|cache|ambiguous/.test(finding.code));
if (!pathFindings.length) lines.push("- None.");
for (const finding of pathFindings) {
lines.push("- [" + finding.severity + "] " + finding.key + " " + finding.code
+ ": " + finding.message
+ (finding.paths.length ? " Paths: " + finding.paths.join(", ") : ""));
}
lines.push("", "## All findings", "");
if (!findings.length) lines.push("- None.");
for (const finding of findings) {
lines.push("- [" + finding.severity + "] " + finding.scope + " " + finding.key
+ " " + finding.code + ": " + finding.message);
}
return lines.join("\n") + "\n";
}
await mkdir(outputRoot, { recursive: true });
await Promise.all([
writeFile(jsonFile, JSON.stringify(report, null, 2) + "\n", "utf8"),
writeFile(markdownFile, renderMarkdown(), "utf8"),
]);
console.log(JSON.stringify({
status: report.status,
json: projectPath(jsonFile),
markdown: projectPath(markdownFile),
summary,
poseOnlyMappings,
}, null, 2));
if (summary.findings.blockers) process.exitCode = 1;