Spaces:
Running
Running
Download engine/io/setup-schema.js from axonis22/muse-engine-32: direct link, hf CLI and curl.
- Browser
- Download file 16.7 kB
-
https://huggingface.co/spaces/axonis22/muse-engine-32/resolve/main/engine/io/setup-schema.js
- Command line
-
hf download hf://spaces/axonis22/muse-engine-32/engine/io/setup-schema.js
-
curl -L -o setup-schema.js https://huggingface.co/spaces/axonis22/muse-engine-32/resolve/main/engine/io/setup-schema.js
16.7 kB
| // engine/io/setup-schema.js - written by Muse Glimmer (Phase 32, part B, 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 () { | |
| 'use strict'; | |
| var KIND = 'muse-engine-setup'; | |
| var VERSION = 1; | |
| var UNITS = ['auto', 'm', 'cm', 'mm', 'in', 'ft']; | |
| var UP = ['auto', 'y', 'z']; | |
| var FACING = ['+z', '-z', '+x', '-x']; | |
| var ROLES = ['idle', 'walk', 'run', 'shoot', 'death']; | |
| var SOURCES = ['url', 'file']; | |
| var MODEL_EXTS = { glb: true, gltf: true, fbx: true }; | |
| var EXTRA_EXTS = { bin: true, png: true, jpg: true, jpeg: true, webp: true, tga: true, ktx2: true }; | |
| // [coach change P32-9] extension point for optional readers (none in the standard build): registerModelExt(ext, { extras }) makes ext a | |
| // model file for planDrop (and no longer an unsupported one); extras: true lets it take dropped .bin / texture files like .gltf / .fbx. | |
| var EXTRA_MAIN_EXTS = { gltf: true, fbx: true }; | |
| var UNSUPPORTED_EXTS = { | |
| obj: true, | |
| blend: true, | |
| max: true, | |
| mb: true, | |
| ma: true, | |
| '3ds': true, | |
| dae: true, | |
| usd: true, | |
| usdz: true, | |
| stl: true, | |
| c4d: true, | |
| skp: true, | |
| }; | |
| function isPlainObject(o) { | |
| return o !== null && typeof o === 'object' && !Array.isArray(o); | |
| } | |
| function lower(s) { | |
| return String(s).toLowerCase(); | |
| } | |
| function extOf(name) { | |
| var i = name.lastIndexOf('.'); | |
| return i >= 0 ? lower(name.slice(i + 1)) : ''; | |
| } | |
| function inArr(v, a) { | |
| return a.indexOf(v) !== -1; | |
| } | |
| function trim(s) { | |
| return String(s).trim(); | |
| } | |
| function normalizeName(n) { | |
| var t = String(n); // [coach fix P32-7] spec: String(s.name || ''); a number name became '' | |
| if (t.length > 260) { | |
| t = t.slice(0, 260); | |
| } | |
| return t; | |
| } | |
| function urlAllowed(u) { | |
| if (typeof u !== 'string') return false; | |
| var s = trim(u); | |
| if (s.length > 2048) return false; // [coach fix P32-5] an empty / blank url is caught by 'url is required', not the URL rule | |
| if (s.startsWith('https://') || s.startsWith('http://')) return true; | |
| var colon = s.indexOf(':'); | |
| var slash = s.indexOf('/'); | |
| if (colon !== -1 && (slash === -1 || colon < slash)) return false; | |
| return true; | |
| } | |
| function slotNormalize(s, slotName, warnings) { | |
| if (s == null) return null; | |
| if (!isPlainObject(s)) return null; | |
| var name = normalizeName(s.name || ''); | |
| if (typeof s.name === 'string' && s.name.length > 260) { | |
| warnings.push(slotName + '.name truncated'); | |
| } | |
| var source = s.source === 'file' ? 'file' : 'url'; | |
| var url = null; | |
| if (source === 'url') { | |
| url = typeof s.url === 'string' ? s.url : null; | |
| } | |
| var units = 'auto'; | |
| if (s.units && inArr(s.units, UNITS)) units = s.units; | |
| else if (s.units) { | |
| warnings.push(slotName + '.units "' + s.units + '" invalid, using auto'); | |
| } | |
| var up = 'auto'; | |
| if (s.up && inArr(s.up, UP)) up = s.up; | |
| else if (s.up) { | |
| warnings.push(slotName + '.up "' + s.up + '" invalid, using auto'); | |
| } | |
| var facing = '+z'; | |
| if (slotName === 'character') { | |
| if (s.facing && inArr(s.facing, FACING)) facing = s.facing; | |
| else if (s.facing) { | |
| warnings.push(slotName + '.facing "' + s.facing + '" invalid, using +z'); | |
| } | |
| } | |
| var clipPicks = {}; | |
| if (slotName === 'character' && isPlainObject(s.clipPicks)) { | |
| for (var i = 0; i < ROLES.length; i++) { | |
| var k = ROLES[i]; | |
| var v = s.clipPicks[k]; | |
| if (v === null || typeof v === 'string') { | |
| clipPicks[k] = v; | |
| } else if (v !== undefined) { | |
| warnings.push(slotName + '.clipPicks.' + k + ' ignored (not a clip name)'); | |
| } | |
| } | |
| for (var kk in s.clipPicks) { | |
| if (s.clipPicks.hasOwnProperty(kk) && inArr(kk, ROLES) === false) { | |
| warnings.push(slotName + '.clipPicks.' + kk + ' ignored'); | |
| } | |
| } | |
| } | |
| if (source === 'file') { | |
| warnings.push(slotName + ' "' + name + '" was loaded from a file: drop that file on the game view to load it'); | |
| } | |
| var out = { name: name, source: source, url: url, asLevel: true, units: units, up: up }; | |
| if (slotName === 'scene') { | |
| out.asLevel = s.asLevel !== false; | |
| } | |
| if (slotName === 'character') { | |
| out.facing = facing; | |
| out.clipPicks = clipPicks; | |
| } | |
| return out; | |
| } | |
| function make(state) { | |
| state = state || {}; // [coach fix P32-7] make() / make(null) threw | |
| // [coach fix P32-7] spec: String(state.x || default); non-string engine / savedAt / look were replaced by the default | |
| var engine = String(state.engine || ''); | |
| var savedAt = String(state.savedAt || ''); | |
| var look = String(state.look || 'A'); | |
| var config = {}; | |
| if (isPlainObject(state.config)) { | |
| var keys = Object.keys(state.config) | |
| .filter(function (k) { | |
| return typeof state.config[k] === 'boolean'; | |
| }) | |
| .sort(); | |
| for (var i = 0; i < keys.length; i++) { | |
| var k = keys[i]; | |
| config[k] = state.config[k]; | |
| } | |
| } | |
| function slot(s, slotName) { | |
| if (s == null) return null; | |
| var name = String(s.name || ''); // [coach fix P32-7] make() keeps the name as given (validate() truncates with a warning) | |
| var source = s.source === 'file' ? 'file' : 'url'; | |
| var url = source === 'url' ? String(s.url || '') : null; | |
| var obj = { name: name, source: source, url: url }; | |
| if (slotName === 'scene') { | |
| obj.asLevel = s.asLevel !== false; | |
| obj.units = inArr(s.units, UNITS) ? s.units : 'auto'; | |
| obj.up = inArr(s.up, UP) ? s.up : 'auto'; | |
| } | |
| if (slotName === 'character') { | |
| obj.units = inArr(s.units, UNITS) ? s.units : 'auto'; | |
| obj.up = inArr(s.up, UP) ? s.up : 'auto'; | |
| obj.facing = inArr(s.facing, FACING) ? s.facing : '+z'; | |
| obj.clipPicks = {}; | |
| if (isPlainObject(s.clipPicks)) { | |
| for (var i = 0; i < ROLES.length; i++) { | |
| var k = ROLES[i]; | |
| var v = s.clipPicks[k]; | |
| if (v === null || typeof v === 'string') obj.clipPicks[k] = v; | |
| } | |
| } | |
| } | |
| return obj; | |
| } | |
| var out = { | |
| kind: KIND, | |
| version: VERSION, | |
| engine: engine, | |
| savedAt: savedAt, | |
| look: look, | |
| config: config, | |
| scene: slot(state.scene, 'scene'), | |
| character: slot(state.character, 'character'), | |
| }; | |
| return out; | |
| } | |
| function validate(obj, opts) { | |
| var errors = []; | |
| var warnings = []; | |
| var value = null; | |
| opts = opts || {}; | |
| if (!isPlainObject(obj)) { | |
| return { ok: false, errors: ['not a JSON object'], warnings: [], value: null }; | |
| } | |
| for (var k in obj) { | |
| if (obj.hasOwnProperty(k) && !['kind', 'version', 'engine', 'savedAt', 'look', 'config', 'scene', 'character'].includes(k)) { | |
| warnings.push('unknown key "' + k + '" ignored'); | |
| } | |
| } | |
| if (obj.kind !== KIND) { | |
| errors.push('kind must be "muse-engine-setup"'); | |
| } | |
| if (typeof obj.version !== 'number' || !Number.isInteger(obj.version) || obj.version < 1) { | |
| errors.push('version must be a positive integer'); | |
| } else if (obj.version > VERSION) { | |
| errors.push('version ' + obj.version + ' is newer than this engine (' + VERSION + ')'); | |
| } | |
| var configObj = {}; | |
| if (isPlainObject(obj.config)) { | |
| var cfg = obj.config; | |
| for (var ck in cfg) { | |
| if (cfg.hasOwnProperty(ck)) { | |
| if (typeof cfg[ck] !== 'boolean') { | |
| warnings.push('config.' + ck + ' ignored (not true/false)'); | |
| continue; | |
| } | |
| if (opts.configKeys && Array.isArray(opts.configKeys) && !inArr(ck, opts.configKeys)) { | |
| warnings.push('config.' + ck + ' ignored (unknown toggle)'); | |
| continue; | |
| } | |
| configObj[ck] = cfg[ck]; | |
| } | |
| } | |
| } else if (obj.config !== undefined) { | |
| warnings.push('config ignored (not an object)'); | |
| } | |
| // [coach fix P32-6] look checked after config (spec warning order); [coach fix P32-4] spec: look kept as String(look) unless opts.looks rejects it; missing look -> 'A' silently. | |
| // Was: any non-string look became 'A' (no warning even when opts.looks was given), and a missing look was warned about. | |
| var look = obj.look === undefined ? 'A' : String(obj.look); | |
| if (obj.look !== undefined && Array.isArray(opts.looks) && !inArr(look, opts.looks)) { | |
| warnings.push('look "' + look + '" unknown, using ' + opts.looks[0]); | |
| look = opts.looks[0]; | |
| } | |
| function checkSlot(slotObj, slotName) { | |
| if (slotObj == null) return null; | |
| if (!isPlainObject(slotObj)) { | |
| errors.push(slotName + ' must be an object or null'); | |
| return null; | |
| } | |
| // [coach fix P32-6] errors in the spec's order: source, url required, url rule, name (name was checked first) | |
| var source = slotObj.source; | |
| if (source !== undefined && !inArr(source, SOURCES)) { | |
| errors.push(slotName + '.source must be url or file'); | |
| } | |
| var url = slotObj.url; | |
| // [coach fix P32-5] url is required only for source 'url' or a missing source (an invalid source like 'ftp' also got this error), | |
| // and the URL rule applies to any string url, also with source 'file' (it was skipped there, and skipped when the url was blank) | |
| if ((source === 'url' || source === undefined) && !(typeof url === 'string' && url.length > 0)) { | |
| errors.push(slotName + '.url is required for source url'); | |
| } | |
| if (typeof url === 'string' && !urlAllowed(url)) { | |
| errors.push(slotName + '.url is not allowed: ' + url); | |
| } | |
| var name = slotObj.name; | |
| if (name !== undefined && typeof name !== 'string') { | |
| errors.push(slotName + '.name must be a string'); | |
| } | |
| return slotNormalize(slotObj, slotName, warnings); | |
| } | |
| var sceneNorm = null, | |
| charNorm = null; | |
| if (obj.scene !== undefined) { | |
| sceneNorm = checkSlot(obj.scene, 'scene'); | |
| } | |
| if (obj.character !== undefined) { | |
| charNorm = checkSlot(obj.character, 'character'); | |
| } | |
| if (errors.length > 0) { | |
| return { ok: false, errors: errors, warnings: warnings, value: null }; | |
| } | |
| var normState = { | |
| engine: typeof obj.engine === 'string' ? obj.engine : '', | |
| savedAt: typeof obj.savedAt === 'string' ? obj.savedAt : '', | |
| look: look, | |
| config: configObj, | |
| scene: sceneNorm, | |
| character: charNorm, | |
| }; | |
| value = make(normState); | |
| return { ok: true, errors: [], warnings: warnings, value: value }; | |
| } | |
| function parse(text, opts) { | |
| var errors = []; | |
| var warnings = []; | |
| var value = null; | |
| if (typeof text !== 'string') { | |
| return { ok: false, errors: ['not valid JSON: input not a string'], warnings: [], value: null }; | |
| } | |
| if (text.length > 262144) { | |
| return { ok: false, errors: ['file too large (max 256 KB)'], warnings: [], value: null }; | |
| } | |
| var obj; | |
| try { | |
| obj = JSON.parse(text); | |
| } catch (e) { | |
| return { ok: false, errors: ['not valid JSON: ' + e.message], warnings: [], value: null }; | |
| } | |
| return validate(obj, opts); | |
| } | |
| function planDrop(files, setup, target) { | |
| var setupIdx = -1; | |
| var sceneMain = -1; | |
| var charMain = -1; | |
| var sceneExtras = []; | |
| var charExtras = []; | |
| var ignored = []; | |
| var errors = []; | |
| var modelIndices = []; | |
| var extraIndices = []; | |
| var otherIndices = []; | |
| var jsonIndices = []; | |
| for (var i = 0; i < files.length; i++) { | |
| var f = files[i]; | |
| var e = extOf(f.name); | |
| if (e === 'json') { | |
| jsonIndices.push(i); | |
| continue; | |
| } | |
| if (MODEL_EXTS[e]) { | |
| modelIndices.push(i); | |
| continue; | |
| } | |
| if (EXTRA_EXTS[e]) { | |
| extraIndices.push(i); | |
| continue; | |
| } | |
| if (UNSUPPORTED_EXTS[e]) { | |
| otherIndices.push({ i: i, name: f.name, ext: e, reason: 'unsupported' }); | |
| continue; | |
| } | |
| otherIndices.push({ i: i, name: f.name, ext: e, reason: 'unknown' }); | |
| } | |
| if (jsonIndices.length > 0) { | |
| setupIdx = jsonIndices[0]; | |
| for (var j = 1; j < jsonIndices.length; j++) { | |
| ignored.push(jsonIndices[j]); | |
| } | |
| } | |
| var setupSceneName = null, | |
| setupCharName = null; | |
| if (setup && setup.scene && setup.scene.name) { | |
| setupSceneName = setup.scene.name.toLowerCase(); | |
| } | |
| if (setup && setup.character && setup.character.name) { | |
| setupCharName = setup.character.name.toLowerCase(); | |
| } | |
| var assignedModel = set(); | |
| function set() { | |
| var s = {}; | |
| return { | |
| has: function (k) { | |
| return s.hasOwnProperty(k); | |
| }, | |
| add: function (k) { | |
| s[k] = true; | |
| }, | |
| get: function () { | |
| return s; | |
| }, | |
| }; | |
| } | |
| // [coach fix P32-8] two passes as specified: first every model whose name matches the setup's scene / character, then the | |
| // remaining models by target. The single pass let an earlier unrelated model take a slot before the matching file was seen | |
| // (e.g. yard.fbx took the scene although swat.glb was the setup's scene). Errors: file-type errors in file order, then no-slot errors. | |
| var rest = []; | |
| for (var m = 0; m < modelIndices.length; m++) { | |
| var idx = modelIndices[m]; | |
| var lname = files[idx].name.toLowerCase(); | |
| if (setupSceneName && lname === setupSceneName && sceneMain === -1) sceneMain = idx; | |
| else if (setupCharName && lname === setupCharName && charMain === -1) charMain = idx; | |
| else rest.push(idx); | |
| } | |
| for (var o = 0; o < otherIndices.length; o++) { | |
| var it = otherIndices[o]; | |
| ignored.push(it.i); | |
| if (it.reason === 'unsupported') { | |
| errors.push(it.name + ': .' + it.ext + ' is not supported, export FBX or glTF (.glb) instead'); | |
| } else { | |
| errors.push(it.name + ': unknown file type'); | |
| } | |
| } | |
| for (var q = 0; q < rest.length; q++) { | |
| var ri = rest[q]; | |
| var matched = false; | |
| if (target === 'scene' && sceneMain === -1) { | |
| sceneMain = ri; | |
| matched = true; | |
| } else if (target === 'character' && charMain === -1) { | |
| charMain = ri; | |
| matched = true; | |
| } else if (setupIdx === -1 && target == null) { | |
| if (sceneMain === -1) { | |
| sceneMain = ri; | |
| matched = true; | |
| } else if (charMain === -1) { | |
| charMain = ri; | |
| matched = true; | |
| } | |
| } | |
| if (!matched) { | |
| ignored.push(ri); | |
| errors.push(files[ri].name + ': dropped with no free slot (drop one model on each half of the game view)'); | |
| } | |
| } | |
| function mainExt(idx) { | |
| if (idx === -1) return null; | |
| return extOf(files[idx].name); | |
| } | |
| var sceneMainExt = mainExt(sceneMain); | |
| var charMainExt = mainExt(charMain); | |
| var sceneCanExtra = !!sceneMainExt && EXTRA_MAIN_EXTS[sceneMainExt] === true; // [coach change P32-9] was: 'gltf' || 'fbx' | |
| var charCanExtra = !!charMainExt && EXTRA_MAIN_EXTS[charMainExt] === true; | |
| for (var eIdx = 0; eIdx < extraIndices.length; eIdx++) { | |
| var idx = extraIndices[eIdx]; | |
| var assigned = false; | |
| if (sceneCanExtra && charCanExtra) { | |
| sceneExtras.push(idx); | |
| charExtras.push(idx); | |
| assigned = true; | |
| } else if (sceneCanExtra) { | |
| sceneExtras.push(idx); | |
| assigned = true; | |
| } else if (charCanExtra) { | |
| charExtras.push(idx); | |
| assigned = true; | |
| } | |
| if (!assigned) { | |
| ignored.push(idx); | |
| } | |
| } | |
| sceneExtras.sort(function (a, b) { | |
| return a - b; | |
| }); | |
| charExtras.sort(function (a, b) { | |
| return a - b; | |
| }); | |
| ignored.sort(function (a, b) { | |
| return a - b; | |
| }); | |
| return { | |
| setup: setupIdx, | |
| scene: { main: sceneMain, extras: sceneExtras }, | |
| character: { main: charMain, extras: charExtras }, | |
| ignored: ignored, | |
| errors: errors, | |
| }; | |
| } | |
| function registerModelExt(ext, opts) { // [coach change P32-9] | |
| ext = String(ext == null ? '' : ext).toLowerCase().replace(/^\./, ''); | |
| if (!/^[a-z0-9]+$/.test(ext) || ext === 'json' || EXTRA_EXTS[ext] === true || MODEL_EXTS[ext] === true) return false; | |
| MODEL_EXTS[ext] = true; | |
| delete UNSUPPORTED_EXTS[ext]; | |
| if (opts && opts.extras) EXTRA_MAIN_EXTS[ext] = true; | |
| return true; | |
| } | |
| var MuseSetup = { KIND: KIND, VERSION: VERSION, make: make, validate: validate, parse: parse, planDrop: planDrop, registerModelExt: registerModelExt }; | |
| if (typeof window !== 'undefined') { | |
| window.MuseSetup = MuseSetup; | |
| } | |
| if (typeof module !== 'undefined' && module.exports) { | |
| module.exports = MuseSetup; | |
| } | |
| })(); | |