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