// engine/check/model-check.js - written by Muse Glimmer (Phase 32, part A, 27 Sep 2026; raw output in game/muse-authored/phase32/raw/), // reviewed by Grok Bot (coach) with game/muse-authored/phase32/harness/. Coach fixes are marked [coach fix P32-n], see FIXES-P32.md. (function () { // [coach fix P32-2] one LIMITS object: analyze() used a private copy, so the exported MuseModelCheck.LIMITS was not the one in force var LIMITS = { sceneTrisWarn: 300000, sceneTrisError: 1000000, charTrisWarn: 100000, charTrisError: 400000, partsWarn: 400, maxBonesDefault: 60, rootMotionMinM: 0.1, rootMotionFrac: 0.05, charHeightMin: 0.5, charHeightMax: 3.0, sceneExtentMin: 2, sceneExtentMax: 5000, fpsLow: 15, }; function medianLower(arr) { var n = arr.length; if (n === 0) return 0; var a = arr.slice().sort(function (a, b) { return a - b; }); var idx = Math.floor((n - 1) / 2); return a[idx]; } function estimateFps(times) { var t = Array.isArray(times) ? times : Array.from(times || []); // [coach fix P32-2] estimateFps() / estimateFps(undefined) threw var n = t.length; if (n === 0) { return { fps: 0, frames: 0, standard: false }; } var diffs = []; for (var i = 1; i < n; i++) { var d = t[i] - t[i - 1]; if (d >= 1e-6) diffs.push(d); } if (diffs.length === 0) { return { fps: 0, frames: n, standard: false }; } var dt = medianLower(diffs); var raw = 1 / dt; var S = [12, 15, 23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60, 100, 120]; var best = S[0]; var bestDiff = Math.abs(raw - best); for (var i = 1; i < S.length; i++) { var d = Math.abs(raw - S[i]); if (d < bestDiff - 1e-12) { bestDiff = d; best = S[i]; } } var s = best; var within = bestDiff <= 0.02 * s; var fps, standard; if (within) { fps = s; standard = true; } else { fps = Math.round(raw * 100) / 100; standard = false; } var duration = t[n - 1] - t[0]; var frames = fps === 0 ? n : Math.round(duration * fps) + 1; return { fps: fps, frames: frames, standard: standard }; } function analyze(facts) { var f = facts || {}; var kind = f.kind || ''; var name = f.name || ''; var format = f.format || ''; var units = f.units || {}; var up = f.up || {}; var triangles = f.triangles || 0; var parts = f.parts || 0; var sizeM = f.sizeM || [0, 0, 0]; var textures = f.textures || {}; var scene = f.scene || {}; var rig = f.rig || {}; var warnings = []; function push(code, level, msg, fix) { warnings.push({ code: code, level: level, msg: msg, fix: fix }); } // rule 1 if (kind === 'character') { var skinned = !!rig.skinned; if (!skinned) { push( 'NO_SKIN', 'warn', 'No skinned mesh / skeleton: drawn as a static model scaled into the enemy box (no animation).', 'Export with Skin/Skinning and Deformations enabled, bind the mesh to the skeleton.', ); // skip 2-6 } else { var bones = rig.bones || 0; var maxBones = rig.maxBones || LIMITS.maxBonesDefault; // [coach fix P32-1] spec: maxBones || default (0 / missing = 60); was != null, so 0 printed "62 bones > 0 GPU slots" var merged = rig.merged || []; if (bones > maxBones) { var first8 = merged.slice(0, 8); var mergedStr = first8.join(', '); if (merged.length > 8) mergedStr += ', …'; var msg = bones + ' bones > ' + maxBones + ' GPU slots: ' + merged.length + ' minor leaf bones merged into their parents'; if (merged.length) msg += ' (' + mergedStr + ')'; msg += '.'; push('BONES_OVER_LIMIT', 'warn', msg, 'Remove or skip fingers/twist/helper bones on export, or accept the merge.'); } var clips = rig.clips || []; if (clips.length === 0) { push( 'NO_CLIPS', 'warn', 'Skinned but no animation takes: the character stands in its bind pose.', 'Bake animation on export (Maya: Bake Animation; Max: Bake Animation) and include the takes.', ); } else { var threshold = Math.max(LIMITS.rootMotionMinM, LIMITS.rootMotionFrac * (rig.heightM || 1.8)); var moving = []; for (var i = 0; i < clips.length; i++) { var c = clips[i]; var rm = c.rootMotionM || 0; if (rm > threshold) { moving.push(c); } } if (moving.length) { var level = moving.every(function (c) { return !!c.stripped; }) ? 'info' : 'warn'; var partsMsg = moving .map(function (c) { return c.name + ' ' + c.rootMotionM.toFixed(2) + ' m'; }) .join(', '); var msg = 'Root motion in ' + moving.length + ' clip(s): ' + partsMsg + (level === 'info' ? ' (stripped: played in place).' : ' (not stripped: the character slides away from its capsule).'); push('ROOT_MOTION', level, msg, 'Keep the toggle "Strip root motion" on, or export in-place animations.'); } var roles = rig.roles || {}; var miss = []; ['idle', 'walk', 'run', 'shoot', 'death'].forEach(function (r) { var idx = roles[r]; if (idx < 0 || idx == null) miss.push(r); }); if (miss.length) { push( 'UNMAPPED_ROLES', 'info', 'No clip found for: ' + miss.join(', ') + ' (pick one in the clip picker, or name takes e.g. Idle, Walk, Run, Shoot, Death).', 'Rename the takes or pick clips in the panel.', ); } var low = []; var odd = []; for (var i = 0; i < clips.length; i++) { var c = clips[i]; var e = estimateFps(c.times || []); if (e.fps > 0 && e.fps < LIMITS.fpsLow) { low.push({ name: c.name, fps: e.fps }); } else if (e.fps > 0 && !e.standard) { odd.push({ name: c.name, fps: e.fps }); } } if (low.length) { var msg = 'Low key rate in: ' + low .map(function (o) { return o.name + ' ' + o.fps + ' fps'; }) .join(', ') + '.'; push('LOW_FPS', 'warn', msg, 'Bake at 30 fps (every frame).'); } else if (odd.length) { var msg = 'Unusual key rate in: ' + odd .map(function (o) { return o.name + ' ' + o.fps + ' fps'; }) .join(', ') + '.'; push('ODD_FPS', 'info', msg, 'Bake every frame at the scene frame rate (24/25/30/60).'); } } } } // rule 7 if (kind === 'character') { var h = rig.heightM || 0; if (h > 0 && (h < LIMITS.charHeightMin || h > LIMITS.charHeightMax)) { push( 'ODD_SIZE', 'warn', 'Character is ' + h.toFixed(2) + ' m tall after unit conversion: check the units / scale.', 'Set the units in the panel (Units) or export in centimetres with unit metadata.', ); } } else if (kind === 'scene') { var ext = Math.max(sizeM[0] || 0, sizeM[1] || 0, sizeM[2] || 0); if (ext > 0 && (ext < LIMITS.sceneExtentMin || ext > LIMITS.sceneExtentMax)) { push( 'ODD_SIZE', 'warn', 'Scene is ' + ext.toFixed(1) + ' m across after unit conversion: check the units / scale.', 'Set the units in the panel (Units) or export in centimetres with unit metadata.', ); } } // rule 8 if (units && units.source === 'guess') { var v = units.value || ''; push( 'NO_UNITS_INFO', 'warn', 'No unit information in the file: guessed ' + v + ' from the size.', 'FBX: keep the file units (Maya: Preferences > Settings > Working units; export with Automatic units). glTF is always metres.', ); } // rule 9 if (up && up.source === 'guess') { var uv = up.value || ''; push( 'NO_UP_INFO', 'warn', 'No up-axis information in the file: assumed ' + uv.toUpperCase() + '-up.', 'FBX: export with Up Axis set (Y or Z); the engine converts Z-up to Y-up.', ); } // rule 10 var missing = textures.missing || []; if (missing.length) { var n = missing.length; var first6 = missing.slice(0, 6); var names = first6.join(', '); if (n > 6) names += ', …'; push( 'MISSING_TEXTURES', 'warn', n + ' texture(s) not found: ' + names + '. Material colours used instead.', 'Embed media in the FBX (Embed Media) or select the texture files together with the model; for .gltf select the .bin and images too.', ); } // rule 11 var warnAt, errorAt; if (kind === 'scene') { warnAt = LIMITS.sceneTrisWarn; errorAt = LIMITS.sceneTrisError; } else { warnAt = LIMITS.charTrisWarn; errorAt = LIMITS.charTrisError; } if (triangles >= errorAt) { push( 'HEAVY_MESH', 'error', triangles.toLocaleString('en-US') + ' triangles: very heavy for WebGL1, expect low frame rates.', 'Reduce polygons (Maya: Mesh > Reduce; Max: ProOptimizer) or remove hidden detail.', ); } else if (triangles >= warnAt) { push( 'HEAVY_MESH', 'warn', triangles.toLocaleString('en-US') + ' triangles: heavy, frame rate may drop on laptops.', 'Reduce polygons (Maya: Mesh > Reduce; Max: ProOptimizer) or remove hidden detail.', ); } // rule 12 if (parts > LIMITS.partsWarn) { push( 'MANY_PARTS', 'info', parts + ' draw parts (meshes x materials): the engine batches parts that share a material.', 'Combine meshes that share a material before export.', ); } // rule 13 if (kind === 'scene' && scene.mode === 'level') { var walkable = scene.walkableTris || 0; if (walkable === 0) { push( 'NO_FLOOR', 'error', 'No walkable floor found (no upward-facing collision triangles): you will fall.', 'Check the up axis / units, and that floors are not named _nocol.', ); } var spawn = scene.spawn; if (spawn !== 'node') { push( 'NO_SPAWN', 'info', 'No PlayerStart node: spawning on the nearest open floor.', 'Add an empty/dummy named PlayerStart where the player should start.', ); } } var order = { error: 0, warn: 1, info: 2 }; warnings.sort(function (a, b) { var oa = order[a.level]; var ob = order[b.level]; if (oa !== ob) return oa - ob; return 0; }); var errors = 0, warns = 0, infos = 0; var codes = []; for (var i = 0; i < warnings.length; i++) { var w = warnings[i]; codes.push(w.code); if (w.level === 'error') errors++; else if (w.level === 'warn') warns++; else infos++; } var level = 'ok'; if (errors > 0) level = 'error'; else if (warns > 0) level = 'warn'; return { level: level, warnings: warnings, summary: { errors: errors, warns: warns, infos: infos, codes: codes } }; } var MuseModelCheck = { analyze: analyze, estimateFps: estimateFps, LIMITS: LIMITS, // [coach fix P32-2] }; if (typeof window !== 'undefined') window.MuseModelCheck = MuseModelCheck; if (typeof module !== 'undefined') module.exports = MuseModelCheck; })();