805 lines
37 KiB
JavaScript
805 lines
37 KiB
JavaScript
#!/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));
|