Files
healer-man/scripts/runewaker-pipeline/audit-runtime-clip-coverage.mjs
T
2026-08-14 15:56:39 -04:00

805 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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("<br>") : "—";
}
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("<br>") : "—") + " |");
}
}
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));