// 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; } })();