#!/usr/bin/env node
import { createHash } from "node:crypto";
import { open, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { inflateSync } from "node:zlib";
import { fileURLToPath } from "node:url";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const recipeRoot = path.join(projectRoot, "scripts", "runewaker-pipeline", "recipes");
const publicRoot = path.join(projectRoot, "public");
const artifactRoot = path.join(projectRoot, "artifacts", "animation-audit");
const jsonFile = path.join(artifactRoot, "runtime-clip-coverage.json");
const markdownFile = path.join(artifactRoot, "runtime-clip-coverage.md");
const runtimeSelectorFile = path.join(projectRoot, "src", "game", "mobAnimation.ts");
const runtimeConsumerFile = path.join(projectRoot, "src", "scene", "MobPopulation.tsx");
const COMBAT_ANIMATION_IDS = {
attack: [16, 17, 18, 19, 85, 87, 88, 95, 57, 58, 118, 53, 54, 32, 33, 2],
cast: [32, 33, 53, 54, 2, 51, 52],
wound: [10, 9, 8],
death: [1, 121, 134, 148, 189],
};
const COMBAT_SEMANTICS = {
attack: /^attack\b/i,
cast: /^cast\b/i,
wound: /^(wound|hurt)\b/i,
death: /^(death|drown)\b/i,
};
const FALLBACK_ORDER = {
idle: ["idle", "moving", "attacking", "dead"],
moving: ["moving", "idle", "attacking", "dead"],
attacking: ["attacking", "idle", "moving", "dead"],
dead: ["dead", "idle", "moving", "attacking"],
};
const ROUND_DIGITS = 6;
const glbCache = new Map();
const readJson = async (file) => JSON.parse((await readFile(file, "utf8")).replace(/^\uFEFF/, ""));
const slash = (file) => path.relative(projectRoot, file).split(path.sep).join("/");
const constantCase = (value) => String(value).replaceAll("-", "_").toUpperCase();
const round = (value) => Number(Number(value).toFixed(ROUND_DIGITS));
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
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 type suffix for " + name + ".");
return JSON.parse(source.slice(valueStart, valueEnd));
}
function parseWoWAnimationName(name) {
const match = String(name).match(/^\s*(.*?)\s*\(ID\s+(\d+)\s+variation\s+(\d+)\)\s*$/i);
return match
? { id: Number.parseInt(match[2], 10), variation: Number.parseInt(match[3], 10) }
: { id: null, variation: 0 };
}
function classifyBaseClip(name) {
const normalized = String(name).trim();
if (/^(death|drown)\b/i.test(normalized)) return "dead";
if (/^attack(?!.*ready)/i.test(normalized)) return "attacking";
if (/^(walk|run)\b/i.test(normalized)) return "moving";
if (/^(stand|idle)\b/i.test(normalized)) return "idle";
return null;
}
function resolveBaseClip(names, requestedState) {
for (const resolvedState of FALLBACK_ORDER[requestedState]) {
const name = names.find((candidate) => classifyBaseClip(candidate) === resolvedState);
if (name) return {
name,
requestedState,
resolvedState,
exact: requestedState === resolvedState,
};
}
return names[0]
? { name: names[0], requestedState, resolvedState: "idle", exact: false }
: null;
}
function combatVariants(names, kind) {
for (const id of COMBAT_ANIMATION_IDS[kind]) {
const matches = names
.filter((name) => parseWoWAnimationName(name).id === id)
.sort((left, right) => (
parseWoWAnimationName(left).variation - parseWoWAnimationName(right).variation
));
if (matches.length) return matches;
}
return names.filter((name) => COMBAT_SEMANTICS[kind].test(String(name).trim()));
}
function revisionCycle(names) {
return names.map((name, revision) => ({ revision, name }));
}
function sampleTimes(duration, singleFrame) {
if (singleFrame || !Number.isFinite(duration) || duration <= 0) return [{ phase: "only", seconds: 0 }];
return [
{ phase: "start", seconds: 0 },
{ phase: "middle", seconds: round(duration / 2) },
{ phase: "end", seconds: round(duration) },
];
}
async function inspectGlb(url) {
if (!glbCache.has(url)) {
glbCache.set(url, (async () => {
const file = path.join(publicRoot, String(url).replace(/^\/+/, ""));
const handle = await open(file, "r");
try {
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 file: " + slash(file));
}
if (header.readUInt32LE(4) !== 2 || header.readUInt32LE(16) !== 0x4e4f534a) {
throw new Error("Unsupported GLB header: " + slash(file));
}
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: " + slash(file));
const json = JSON.parse(jsonBytes.toString("utf8").replace(/\0+$/, "").trim());
const animations = (json.animations ?? []).map((animation, index) => {
const inputAccessors = [...new Set((animation.samplers ?? []).map((sampler) => sampler.input))]
.map((accessorIndex) => json.accessors?.[accessorIndex])
.filter(Boolean);
const duration = Math.max(0, ...inputAccessors.map((accessor) => Number(accessor.max?.[0] ?? 0)));
const frameCounts = inputAccessors.map((accessor) => Number(accessor.count ?? 0));
const maximumKeyframes = Math.max(0, ...frameCounts);
const minimumKeyframes = Math.min(...frameCounts, maximumKeyframes);
const singleFrame = maximumKeyframes <= 1 || duration <= 0.000001;
return {
name: animation.name ?? "animation-" + index,
manifestIndex: index,
durationSeconds: round(duration),
channels: animation.channels?.length ?? 0,
minimumKeyframes,
maximumKeyframes,
singleFrame,
sampleTimes: sampleTimes(duration, singleFrame),
};
});
return {
file: slash(file),
fileSha256: sha256(await readFile(file)),
animations,
skins: json.skins?.length ?? 0,
};
} finally {
await handle.close();
}
})());
}
return glbCache.get(url);
}
function clipDetail(name, metadataByName) {
if (!name) return null;
const metadata = metadataByName.get(name);
return metadata ? { ...metadata } : {
name,
durationSeconds: null,
channels: null,
minimumKeyframes: null,
maximumKeyframes: null,
singleFrame: null,
sampleTimes: [],
metadataMissing: true,
};
}
function familyPlan(names, metadataByName, kind) {
const variants = combatVariants(names, kind);
return {
selection: "revision modulo every variant in the highest-priority numeric ID, otherwise manifest-order semantic matches",
revisionCycle: revisionCycle(variants),
clips: variants.map((name) => clipDetail(name, metadataByName)),
};
}
function semanticNearMisses(names) {
return {
idle: names.filter((name) => /^Action - .*\b(idle|stand)\b/i.test(name)),
moving: names.filter((name) => /^Action - .*\b(walk|run)\b/i.test(name)),
attack: names.filter((name) => /^Action - .*attack/i.test(name)),
cast: names.filter((name) => /^Action - .*cast/i.test(name)),
wound: names.filter((name) => /^Action - .*\b(hurt|wound)\b/i.test(name)),
death: names.filter((name) => /^(Action|Terminal Pose) - .*\b(dead|death|drown)\b/i.test(name)),
};
}
function validateRuntimeContract(selectorSource, consumerSource) {
const checks = [
["base-first-match", /clips\.find\(\(candidate\) => classifyAnimationClip\(candidate\.name\) === resolvedState\)/],
["combat-modulo-rotation", /semanticClips\[Math\.abs\(revision\) % semanticClips\.length\]/],
["idle-moving-dead-base-consumer", /resolveMobAnimationClip\(gltf\.animations, animationState\)/],
["attack-cast-wound-one-shots", /playCombatAction = \(kind: "attack" \| "cast" \| "wound"/],
["death-not-dispatched-as-one-shot", /if \(animationState === "dead"\) return;/],
];
const results = checks.map(([id, pattern]) => ({
id,
matched: pattern.test(id.startsWith("base") || id.startsWith("combat") ? selectorSource : consumerSource),
}));
if (results.some((check) => !check.matched)) {
throw new Error("Runtime animation-selection contract changed: "
+ results.filter((check) => !check.matched).map((check) => check.id).join(", "));
}
return results;
}
function paeth(left, up, upperLeft) {
const estimate = left + up - upperLeft;
const leftDistance = Math.abs(estimate - left);
const upDistance = Math.abs(estimate - up);
const upperLeftDistance = Math.abs(estimate - upperLeft);
return leftDistance <= upDistance && leftDistance <= upperLeftDistance
? left : upDistance <= upperLeftDistance ? up : upperLeft;
}
async function pngColorSilhouette(file, minimumChannelSpread = 10) {
const bytes = await readFile(file);
if (bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a") throw new Error("Not a PNG: " + slash(file));
let offset = 8;
let width = 0;
let height = 0;
let bitDepth = 0;
let colorType = 0;
const idat = [];
while (offset < bytes.length) {
const length = bytes.readUInt32BE(offset);
const type = bytes.toString("ascii", offset + 4, offset + 8);
const data = bytes.subarray(offset + 8, offset + 8 + length);
if (type === "IHDR") {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
bitDepth = data[8];
colorType = data[9];
} else if (type === "IDAT") idat.push(data);
offset += length + 12;
if (type === "IEND") break;
}
if (bitDepth !== 8 || ![2, 6].includes(colorType)) {
throw new Error("PNG silhouette audit supports only 8-bit RGB/RGBA: " + slash(file));
}
const bytesPerPixel = colorType === 6 ? 4 : 3;
const stride = width * bytesPerPixel;
const filtered = inflateSync(Buffer.concat(idat));
const pixels = Buffer.alloc(stride * height);
let sourceOffset = 0;
for (let y = 0; y < height; y += 1) {
const filter = filtered[sourceOffset++];
for (let x = 0; x < stride; x += 1) {
const raw = filtered[sourceOffset++];
const index = y * stride + x;
const left = x >= bytesPerPixel ? pixels[index - bytesPerPixel] : 0;
const up = y > 0 ? pixels[index - stride] : 0;
const upperLeft = y > 0 && x >= bytesPerPixel ? pixels[index - stride - bytesPerPixel] : 0;
const predictor = filter === 0 ? 0
: filter === 1 ? left
: filter === 2 ? up
: filter === 3 ? Math.floor((left + up) / 2)
: filter === 4 ? paeth(left, up, upperLeft)
: null;
if (predictor === null) throw new Error("Unsupported PNG filter " + filter + ".");
pixels[index] = (raw + predictor) & 0xff;
}
}
let selectedPixels = 0;
let minX = width;
let maxX = -1;
let minY = height;
let maxY = -1;
let sumX = 0;
let sumY = 0;
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const index = y * stride + x * bytesPerPixel;
const red = pixels[index];
const green = pixels[index + 1];
const blue = pixels[index + 2];
if (Math.max(red, green, blue) - Math.min(red, green, blue) < minimumChannelSpread) continue;
selectedPixels += 1;
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
sumX += x;
sumY += y;
}
}
return {
file: slash(file),
method: "foreground heuristic: max(R,G,B)-min(R,G,B) >= " + minimumChannelSpread,
imageWidth: width,
imageHeight: height,
selectedPixels,
bounds: selectedPixels ? {
minX, maxX, minY, maxY,
width: maxX - minX + 1,
height: maxY - minY + 1,
} : null,
centroid: selectedPixels ? {
x: round(sumX / selectedPixels),
y: round(sumY / selectedPixels),
} : null,
};
}
async function pasperDiagnostics() {
const base = path.join(projectRoot, "artifacts", "animation-diagnostics");
const preFix = await pngColorSilhouette(path.join(base, "fog-ferocity-03", "Bind-Pose.png"));
const fixed = await pngColorSilhouette(path.join(base, "fog-ferocity-03-fixed", "Bind-Pose.png"));
const fixedFiles = (await readdir(path.join(base, "fog-ferocity-03-fixed")))
.filter((file) => file.endsWith(".png") && file !== "Bind-Pose.png")
.sort();
const fixedSamples = [];
for (const file of fixedFiles) {
fixedSamples.push(await pngColorSilhouette(path.join(base, "fog-ferocity-03-fixed", file)));
}
const widthRatio = round(preFix.bounds.width / fixed.bounds.width);
const heightRatio = round(preFix.bounds.height / fixed.bounds.height);
const pixelRatio = round(preFix.selectedPixels / fixed.selectedPixels);
const maximumFixedWidthRatio = round(Math.max(...fixedSamples.map((sample) => (
sample.bounds.width / fixed.bounds.width
))));
const maximumFixedHeightRatio = round(Math.max(...fixedSamples.map((sample) => (
sample.bounds.height / fixed.bounds.height
))));
return {
evidenceKind: "same-camera color-silhouette comparison; useful regression evidence, not a substitute for vertex-space checks",
preFix,
fixed,
fixedAnimationSamples: fixedSamples,
comparison: {
preFixToFixedBindWidthRatio: widthRatio,
preFixToFixedBindHeightRatio: heightRatio,
preFixToFixedColoredPixelRatio: pixelRatio,
maximumFixedAnimationToBindWidthRatio: maximumFixedWidthRatio,
maximumFixedAnimationToBindHeightRatio: maximumFixedHeightRatio,
interpretation: "The broken bind pose was over twice the repaired silhouette width while height stayed similar; repaired sampled motion remained below the recommended goatman width threshold.",
},
};
}
function markdownClipList(plan) {
return plan.clips.length ? plan.clips.map((clip) => clip.name).join("
") : "—";
}
function markdown(report) {
const lines = [
"# RuneWaker runtime clip coverage",
"",
"Generated by `node scripts/runewaker-pipeline/audit-runtime-clip-coverage.mjs`.",
"",
"Status: **" + report.status.toUpperCase() + "**",
"",
"Shipping manifest animation order is authoritative. GLB metadata is used only to cross-check names and measure duration/channel/keyframe quality.",
"",
"## Runtime selector contract",
"",
"- Idle and moving loop the **first** manifest clip classified into that state; alternate idle/walk/run clips do not rotate.",
"- Attack, cast, and wound one-shots rotate by `abs(revision) % variationCount` across semantic matches in manifest order (or the highest-priority numeric WoW ID).",
"- Dead state loops nothing: it plays the first `Death`/`Drown` base clip once and clamps at its end.",
"- The death combat-selector can enumerate revision variants, but `MobPopulation` does not dispatch death as a combat one-shot today. The sample plan still includes every selector-visible death variant.",
"- Missing attack/cast/wound/death clips receive bounded procedural presentation; missing idle/moving clips fall through the base-state order.",
"",
"## Totals",
"",
"| Dungeons | Templates | Configured base actors | Configured packaged variants | Native variants | Procedural/pose variants | Missing runtime-required families | Selector-visible single-frame clips |",
"|---:|---:|---:|---:|---:|---:|---:|---:|",
"| " + [
report.totals.dungeons,
report.totals.configuredTemplates,
report.totals.configuredBaseActors,
report.totals.configuredActorAssets,
report.totals.nativeActorAssets,
report.totals.proceduralActorAssets,
report.totals.templatesMissingRuntimeRequiredFamily,
report.totals.selectorVisibleSingleFrameClips,
].join(" | ") + " |",
"",
"Native/packaged family coverage (asset variants, not template rows): idle **"
+ report.totals.assetsWithExactIdle + "/" + report.totals.configuredActorAssets
+ "**, moving **" + report.totals.assetsWithExactMoving + "/" + report.totals.configuredActorAssets
+ "**, attack **" + report.totals.assetsWithAttackFamily + "/" + report.totals.configuredActorAssets
+ "**, cast **" + report.totals.assetsWithCastFamily + "/" + report.totals.configuredActorAssets
+ "**, wound **" + report.totals.assetsWithWoundFamily + "/" + report.totals.configuredActorAssets
+ "**, death **" + report.totals.assetsWithExactDeath + "/" + report.totals.configuredActorAssets + "**.",
"",
"Native variants missing at least one standard family: **"
+ report.totals.nativeAssetsMissingAnyStandardFamily + "**; variants with a semantic-looking `Action - ...` near-miss: **"
+ report.totals.nativeAssetsWithSemanticNearMiss + "**.",
"",
"## Dungeon summary",
"",
"| Dungeon | Templates | Actors | Variants | Native | Procedural | Missing families | Suspicious clips |",
"|---|---:|---:|---:|---:|---:|---:|---:|",
];
for (const dungeon of report.dungeons) {
lines.push("| " + dungeon.dungeonId + " | " + [
dungeon.configuredTemplates,
dungeon.configuredBaseActors,
dungeon.configuredActorAssets,
dungeon.nativeActorAssets,
dungeon.proceduralActorAssets,
dungeon.templatesMissingRuntimeRequiredFamily,
dungeon.suspiciousClips.length,
].join(" | ") + " |");
}
lines.push(
"",
"## Per-asset visual sample plan",
"",
"Every configured manifest variant is listed, including appearance/equipment variants sharing a base rig.",
"",
"| Dungeon | Asset | Mode | Idle (first) | Moving (first) | Attack rotation | Cast rotation | Wound rotation | Death selector rotation | Dead base (first) | Flags |",
"|---|---|---|---|---|---|---|---|---|---|---|",
);
for (const dungeon of report.dungeons) {
const plans = report.samplePlanByDungeonAndAsset[dungeon.dungeonId];
for (const plan of Object.values(plans)) {
lines.push("| " + dungeon.dungeonId + " | " + plan.assetId + " | " + plan.animationMode + " | "
+ (plan.base.idle?.clip?.name ?? "—") + " | "
+ (plan.base.moving?.clip?.name ?? "—") + " | "
+ markdownClipList(plan.combat.attack) + " | "
+ markdownClipList(plan.combat.cast) + " | "
+ markdownClipList(plan.combat.wound) + " | "
+ markdownClipList(plan.combat.death) + " | "
+ (plan.base.dead?.clip?.name ?? "—") + " | "
+ (plan.flags.length ? plan.flags.join("
") : "—") + " |");
}
}
lines.push(
"",
"## Every configured template",
"",
"| Dungeon | Template | Kind | Actor | Runtime asset | Attack events | Required native families | Missing |",
"|---|---|---|---|---|---|---|---|",
);
for (const template of report.templates) {
lines.push("| " + template.dungeonId + " | " + template.templateId + " " + template.name + " | "
+ template.classification + " | " + template.actorId + " | "
+ (template.runtimeAssetId ?? "not instantiated") + " | "
+ (template.attackAnimationKinds.length ? template.attackAnimationKinds.join(", ") : "none") + " | "
+ template.requiredNativeFamilies.join(", ") + " | "
+ (template.missingRuntimeRequiredFamilies.length ? template.missingRuntimeRequiredFamilies.join(", ") : "—") + " |");
}
lines.push(
"",
"## Suspicious and unreachable clips",
"",
);
if (!report.suspiciousClips.length) lines.push("No suspicious clips found.");
else for (const issue of report.suspiciousClips) {
lines.push("- `" + issue.dungeonId + ":" + issue.assetId + "` `" + issue.clip + "` ["
+ issue.code + "] " + issue.message);
}
lines.push(
"",
"## Pasper goatman pre-fix versus repaired diagnostic",
"",
"The retained same-camera bind screenshots give a reproducible color-silhouette signal. The broken bind width was **"
+ report.pasperGoatmanDiagnostics.comparison.preFixToFixedBindWidthRatio
+ "×** the repaired width, while height was **"
+ report.pasperGoatmanDiagnostics.comparison.preFixToFixedBindHeightRatio
+ "×**. The widest repaired idle/run/attack sample was **"
+ report.pasperGoatmanDiagnostics.comparison.maximumFixedAnimationToBindWidthRatio
+ "×** repaired bind width.",
"",
"Recommended automated thresholds:",
"",
);
for (const threshold of report.recommendedDeformationThresholds) {
lines.push("- **" + threshold.metric + "**: warn at " + threshold.warning + "; fail at "
+ threshold.failure + ". " + threshold.reason);
}
lines.push(
"",
"## Limitations",
"",
);
for (const limitation of report.limitations) lines.push("- " + limitation);
return lines.join("\n") + "\n";
}
const selectorBytes = await readFile(runtimeSelectorFile);
const consumerBytes = await readFile(runtimeConsumerFile);
const selectorSource = selectorBytes.toString("utf8");
const consumerSource = consumerBytes.toString("utf8");
const runtimeContractChecks = validateRuntimeContract(selectorSource, consumerSource);
const recipeFiles = (await readdir(recipeRoot))
.filter((file) => file.endsWith("-population.json"))
.sort();
const dungeons = [];
const templates = [];
const suspiciousClips = [];
const samplePlanByDungeonAndAsset = {};
const configuredActorKeys = new Set();
for (const recipeName of recipeFiles) {
const recipeFile = path.join(recipeRoot, recipeName);
const recipe = await readJson(recipeFile);
const configuredTemplates = recipe.templates.filter((template) => ["combat", "boss"].includes(template.classification));
const configuredActorIds = new Set(configuredTemplates.map((template) => template.actor).filter(Boolean));
const actors = new Map(recipe.actors.map((actor) => [actor.id, actor]));
const manifestFile = path.resolve(projectRoot, recipe.files.actorManifest);
const manifest = await readJson(manifestFile);
const generatedFile = path.resolve(projectRoot, recipe.files.generatedSource);
const generatedSource = await readFile(generatedFile, "utf8");
const prefix = constantCase(recipe.dungeonId);
const entities = generatedJson(generatedSource, prefix + "_ENTITIES", " as const satisfies PopulationDefinitionMap;");
const manifestAssets = (manifest.assets ?? []).filter((asset) => configuredActorIds.has(asset.baseActorId ?? asset.id));
const assetByUrl = new Map(manifestAssets.map((asset) => [asset.url, asset]));
const assetsByActor = new Map();
for (const asset of manifestAssets) {
const actorId = asset.baseActorId ?? asset.id;
const selected = assetsByActor.get(actorId) ?? [];
selected.push(asset);
assetsByActor.set(actorId, selected);
}
const attackKindsByActor = new Map([...configuredActorIds].map((actorId) => [actorId, new Set()]));
for (const template of configuredTemplates) {
const entityId = recipe.runtimeIdPrefix + "-" + template.id;
const entity = entities[entityId] ?? null;
for (const attack of entity?.combat?.attacks ?? []) {
if (["attack", "cast"].includes(attack.animation)) attackKindsByActor.get(template.actor)?.add(attack.animation);
}
}
const plans = {};
for (const asset of manifestAssets) {
const actorId = asset.baseActorId ?? asset.id;
const actor = actors.get(actorId);
const glb = await inspectGlb(asset.url);
const manifestNames = [...(asset.animations ?? [])];
const glbNames = glb.animations.map((animation) => animation.name);
const metadataByName = new Map(glb.animations.map((animation) => [animation.name, animation]));
const idle = resolveBaseClip(manifestNames, "idle");
const moving = resolveBaseClip(manifestNames, "moving");
const dead = resolveBaseClip(manifestNames, "dead");
const attack = familyPlan(manifestNames, metadataByName, "attack");
const cast = familyPlan(manifestNames, metadataByName, "cast");
const wound = familyPlan(manifestNames, metadataByName, "wound");
const death = familyPlan(manifestNames, metadataByName, "death");
const nearMisses = semanticNearMisses(manifestNames);
const actorAttackKinds = [...(attackKindsByActor.get(actorId) ?? [])].sort();
const animationMode = actor?.animationException?.kind === "pose-only" ? "procedural" : "native";
const flags = [];
if (manifestNames.join("\n") !== glbNames.join("\n")) flags.push("manifest-glb-clip-order-mismatch");
if (!idle?.exact) flags.push("missing-exact-idle");
if (!moving?.exact) flags.push("missing-exact-moving");
if (!dead?.exact) flags.push("missing-exact-death");
if (!wound.clips.length) flags.push("missing-wound");
for (const kind of actorAttackKinds) {
if (!(kind === "attack" ? attack : cast).clips.length) flags.push("missing-configured-" + kind);
}
const standardFamilyGaps = [
!idle?.exact ? "idle" : null,
!moving?.exact ? "moving" : null,
!attack.clips.length ? "attack" : null,
!cast.clips.length ? "cast" : null,
!wound.clips.length ? "wound" : null,
!dead?.exact ? "death" : null,
].filter(Boolean);
for (const family of standardFamilyGaps) flags.push("standard-family-missing:" + family);
for (const family of standardFamilyGaps) {
if (nearMisses[family]?.length) flags.push("semantic-near-miss:" + family);
}
if (animationMode === "procedural") flags.push("documented-pose-only-procedural-mode");
const selectedNames = new Set([
idle?.name, moving?.name, dead?.name,
...attack.clips.map((clip) => clip.name),
...cast.clips.map((clip) => clip.name),
...wound.clips.map((clip) => clip.name),
...death.clips.map((clip) => clip.name),
].filter(Boolean));
for (const animation of glb.animations) {
const selectorVisible = selectedNames.has(animation.name);
if (animation.singleFrame) {
suspiciousClips.push({
dungeonId: recipe.dungeonId,
actorId,
assetId: asset.id,
clip: animation.name,
code: selectorVisible ? "selector-visible-single-frame" : "unreachable-single-frame",
message: (selectorVisible ? "A selector-visible" : "A currently unreachable") + " clip has "
+ animation.maximumKeyframes + " keyframe(s) over " + animation.durationSeconds + " seconds.",
});
} else if (/^Terminal Pose\b/i.test(animation.name)) {
suspiciousClips.push({
dungeonId: recipe.dungeonId,
actorId,
assetId: asset.id,
clip: animation.name,
code: "terminal-pose-not-runtime-classified",
message: "The exported terminal pose is intentionally not classified by the current base or death one-shot selectors; the full Death clip supplies runtime death.",
});
} else if (animation.durationSeconds < 0.1 && selectorVisible) {
suspiciousClips.push({
dungeonId: recipe.dungeonId,
actorId,
assetId: asset.id,
clip: animation.name,
code: "selector-visible-short-clip",
message: "A selector-visible clip is shorter than 0.1 seconds.",
});
}
}
const templateIds = configuredTemplates.filter((template) => template.actor === actorId).map((template) => template.id);
if (plans[asset.id]) throw new Error("Duplicate configured asset id in " + recipe.dungeonId + ": " + asset.id);
plans[asset.id] = {
dungeonId: recipe.dungeonId,
actorId,
assetId: asset.id,
baseActorId: asset.baseActorId ?? asset.id,
appearanceSignature: asset.appearanceSignature ?? null,
templateIds,
url: asset.url,
glb: glb.file,
glbSha256: glb.fileSha256,
animationMode,
configuredAttackAnimationKinds: actorAttackKinds,
standardFamilyGaps,
semanticNearMisses: nearMisses,
manifestIsAuthoritative: true,
manifestClipCount: manifestNames.length,
manifestAnimations: manifestNames,
manifestGlbExactOrderMatch: manifestNames.join("\n") === glbNames.join("\n"),
base: {
idle: idle ? { ...idle, clip: clipDetail(idle.name, metadataByName) } : null,
moving: moving ? { ...moving, clip: clipDetail(moving.name, metadataByName) } : null,
dead: dead ? { ...dead, clip: clipDetail(dead.name, metadataByName) } : null,
},
combat: { attack, cast, wound, death },
runtimeNotes: {
idleAndMovingVariantRotation: false,
attackCastWoundVariantRotation: true,
deathCombatSelectorInvokedByMobPopulation: false,
deathBasePlaysOnceAndClamps: true,
},
flags,
};
}
samplePlanByDungeonAndAsset[recipe.dungeonId] = plans;
let templatesMissingRuntimeRequiredFamily = 0;
for (const template of configuredTemplates) {
const entityId = recipe.runtimeIdPrefix + "-" + template.id;
const entity = entities[entityId] ?? null;
const runtimeAsset = entity?.visual?.model?.url ? assetByUrl.get(entity.visual.model.url) ?? null : null;
const actorAssets = assetsByActor.get(template.actor) ?? [];
const attackAnimationKinds = [...new Set((entity?.combat?.attacks ?? [])
.map((attack) => attack.animation)
.filter((animation) => ["attack", "cast"].includes(animation)))].sort();
const requiredNativeFamilies = ["idle", "moving", ...attackAnimationKinds, "wound", "death"];
const selectedPlan = runtimeAsset ? plans[runtimeAsset.id] : null;
const missingRuntimeRequiredFamilies = [];
if (!selectedPlan?.base.idle?.exact) missingRuntimeRequiredFamilies.push("idle");
if (!selectedPlan?.base.moving?.exact) missingRuntimeRequiredFamilies.push("moving");
for (const kind of attackAnimationKinds) {
if (!selectedPlan?.combat[kind]?.clips.length) missingRuntimeRequiredFamilies.push(kind);
}
if (!selectedPlan?.combat.wound.clips.length) missingRuntimeRequiredFamilies.push("wound");
if (!selectedPlan?.base.dead?.exact) missingRuntimeRequiredFamilies.push("death");
if (missingRuntimeRequiredFamilies.length) templatesMissingRuntimeRequiredFamily += 1;
templates.push({
dungeonId: recipe.dungeonId,
templateId: template.id,
entityId: entity?.id ?? null,
name: template.name,
classification: template.classification,
actorId: template.actor,
configuredActorAssetIds: actorAssets.map((asset) => asset.id),
runtimeAssetId: runtimeAsset?.id ?? null,
runtimeAssetUrl: runtimeAsset?.url ?? entity?.visual?.model?.url ?? null,
animationMode: entity?.visual?.model?.animationMode ?? (actors.get(template.actor)?.animationException ? "procedural" : "native"),
attackAnimationKinds,
requiredNativeFamilies,
missingRuntimeRequiredFamilies,
proceduralFallbackCoversMissingCombatPresentation: missingRuntimeRequiredFamilies.length > 0,
});
}
const dungeonSuspicious = suspiciousClips.filter((issue) => issue.dungeonId === recipe.dungeonId);
const proceduralActorAssets = Object.values(plans).filter((plan) => plan.animationMode === "procedural").length;
dungeons.push({
dungeonId: recipe.dungeonId,
recipe: slash(recipeFile),
manifest: slash(manifestFile),
manifestStatus: manifest.status,
configuredTemplates: configuredTemplates.length,
configuredBaseActors: configuredActorIds.size,
configuredActorAssets: manifestAssets.length,
nativeActorAssets: manifestAssets.length - proceduralActorAssets,
proceduralActorAssets,
templatesMissingRuntimeRequiredFamily,
suspiciousClips: dungeonSuspicious,
});
for (const actorId of configuredActorIds) configuredActorKeys.add(recipe.dungeonId + ":" + actorId);
}
templates.sort((left, right) => left.dungeonId.localeCompare(right.dungeonId)
|| String(left.templateId).localeCompare(String(right.templateId), undefined, { numeric: true }));
suspiciousClips.sort((left, right) => left.dungeonId.localeCompare(right.dungeonId)
|| left.assetId.localeCompare(right.assetId) || left.clip.localeCompare(right.clip));
const totals = {
dungeons: dungeons.length,
configuredTemplates: templates.length,
configuredBaseActors: configuredActorKeys.size,
configuredActorAssets: dungeons.reduce((sum, dungeon) => sum + dungeon.configuredActorAssets, 0),
nativeActorAssets: dungeons.reduce((sum, dungeon) => sum + dungeon.nativeActorAssets, 0),
proceduralActorAssets: dungeons.reduce((sum, dungeon) => sum + dungeon.proceduralActorAssets, 0),
templatesMissingRuntimeRequiredFamily: templates.filter((template) => template.missingRuntimeRequiredFamilies.length).length,
selectorVisibleSingleFrameClips: suspiciousClips.filter((issue) => issue.code === "selector-visible-single-frame").length,
unreachableSingleFrameClips: suspiciousClips.filter((issue) => issue.code === "unreachable-single-frame").length,
terminalPoseClipsNotRuntimeClassified: suspiciousClips.filter((issue) => issue.code === "terminal-pose-not-runtime-classified").length,
};
const allAssetPlans = Object.values(samplePlanByDungeonAndAsset).flatMap((plans) => Object.values(plans));
Object.assign(totals, {
assetsWithExactIdle: allAssetPlans.filter((plan) => plan.base.idle?.exact).length,
assetsWithExactMoving: allAssetPlans.filter((plan) => plan.base.moving?.exact).length,
assetsWithAttackFamily: allAssetPlans.filter((plan) => plan.combat.attack.clips.length).length,
assetsWithCastFamily: allAssetPlans.filter((plan) => plan.combat.cast.clips.length).length,
assetsWithWoundFamily: allAssetPlans.filter((plan) => plan.combat.wound.clips.length).length,
assetsWithExactDeath: allAssetPlans.filter((plan) => plan.base.dead?.exact).length,
nativeAssetsMissingAnyStandardFamily: allAssetPlans.filter((plan) => (
plan.animationMode === "native" && plan.standardFamilyGaps.length
)).length,
nativeAssetsWithSemanticNearMiss: allAssetPlans.filter((plan) => (
plan.animationMode === "native" && plan.standardFamilyGaps.some((family) => plan.semanticNearMisses[family]?.length)
)).length,
});
const report = {
schemaVersion: 1,
status: totals.templatesMissingRuntimeRequiredFamily || totals.selectorVisibleSingleFrameClips ? "review" : "pass",
generatedAt: new Date().toISOString(),
authority: {
manifestAnimations: "authoritative for clip order and selector input",
glbJson: "cross-check and duration/channel/keyframe metadata",
runtimeSelector: slash(runtimeSelectorFile),
runtimeConsumer: slash(runtimeConsumerFile),
runtimeSelectorSha256: sha256(selectorBytes),
runtimeConsumerSha256: sha256(consumerBytes),
contractChecks: runtimeContractChecks,
},
totals,
dungeons,
templates,
samplePlanByDungeonAndAsset,
suspiciousClips,
pasperGoatmanDiagnostics: await pasperDiagnostics(),
recommendedDeformationThresholds: [
{
metric: "Pasper goatman same-camera silhouette width / repaired bind width",
warning: "> 1.85x",
failure: "> 2.00x",
reason: "Repaired sampled motion peaks at 1.70x; the retained broken bind is 2.12x.",
},
{
metric: "Bind-pose AABB extent versus static/unskinned reference (each axis)",
warning: "> 1.05x or < 0.95x",
failure: "> 1.10x or < 0.90x",
reason: "A bind pose should reproduce the source mesh; normal animation amplitude is irrelevant to this comparison.",
},
{
metric: "Sampled skinned triangle-edge length / bind triangle-edge length",
warning: "p99 > 1.35x or max > 2.00x",
failure: "p99 > 1.60x or max > 2.50x",
reason: "Catches isolated vertex-to-bone or inverse-bind failures while tolerating ordinary linear-skinning stretch.",
},
{
metric: "Sampled vertex distance from bind center / bind bounding-sphere radius",
warning: "max > 2.00x",
failure: "max > 2.50x",
reason: "Catches exploded limbs and seam spikes; review winged or intentionally telescoping actors separately.",
},
{
metric: "Finite sampled transforms and normalized quaternions",
warning: "|quaternion length - 1| > 0.001",
failure: "any NaN/Infinity or |quaternion length - 1| > 0.01",
reason: "The Pasper repair involved skeletal transform integrity; invalid or non-unit rotations must fail before rendering.",
},
],
limitations: [
"This audit proves selector reachability and clip metadata, not visual correctness of all 322 actor variants. The generated sample plan is the input contract for the Blender/browser pose-audit subprocess.",
"GLB accessor min/max values expose duration and keyframe counts without decoding meshopt streams; vertex-space deformation thresholds still need the pose-audit renderer or a meshopt-aware sampler.",
"A template with no active runtime entity cannot identify one appearance variant as its runtime asset; every manifest variant for its configured base actor remains in the per-asset sample plan.",
"Terminal Pose - dead exports are retained as forensic/pose data but are not currently runtime-classified. Runtime dead state selects the full Death/Drown clip and clamps it.",
],
};
await mkdir(artifactRoot, { recursive: true });
await writeFile(jsonFile, JSON.stringify(report, null, 2) + "\n", "utf8");
await writeFile(markdownFile, markdown(report), "utf8");
console.log("Wrote " + slash(jsonFile));
console.log("Wrote " + slash(markdownFile));
console.log(JSON.stringify({ status: report.status, totals }, null, 2));