@hyprune/schema 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/validate.mjs CHANGED
@@ -3,6 +3,8 @@ import Ajv2020 from 'ajv/dist/2020.js';
3
3
  import parseSpdx from 'spdx-expression-parse';
4
4
 
5
5
  const names = ['common', 'world', 'extension', 'screen-type', 'ipc'];
6
+ // Object, library and library release record schemas (RFC-0011) live in v0.N directories only.
7
+ const manifestKinds = ['object', 'library', 'library-releases'];
6
8
  const ajv = new Ajv2020({ allErrors: true, strict: true });
7
9
  const base = 'https://hyprune.com/schema/v0/';
8
10
  for (const name of names) {
@@ -14,7 +16,7 @@ const root = new URL('../', import.meta.url);
14
16
  const byVersion = (a, b) => { const [x, y] = [a, b].map(v => v.split('.').map(Number)); return x[0] - y[0] || x[1] - y[1]; };
15
17
  const versions = readdirSync(root).filter(d => /^v0\.\d+$/.test(d)).map(d => d.slice(1)).sort(byVersion);
16
18
  const has = (version, name) => existsSync(new URL(`v${version}/${name}.schema.json`, root));
17
- for (const v of versions) for (const name of ['common', 'world', 'ipc'])
19
+ for (const v of versions) for (const name of ['common', 'world', 'ipc', ...manifestKinds])
18
20
  if (has(v, name)) ajv.addSchema(JSON.parse(readFileSync(new URL(`v${v}/${name}.schema.json`, root))));
19
21
  const validators = new Map();
20
22
 
@@ -31,6 +33,9 @@ export const worldFormats = ['0.1', ...versions.filter(v => has(v, 'world'))];
31
33
  // versions are all of them (AGENTS rule 6: no hand-kept version lists).
32
34
  export const ipcProtocols = ['0.1', ...versions.filter(v => has(v, 'ipc'))];
33
35
  export const contractVersions = ['0.1', ...versions];
36
+ // Object manifest formats (RFC-0011): every v0.N directory with an object schema.
37
+ // They share the world format numbering and start at 0.9.
38
+ export const objectFormats = versions.filter(v => has(v, 'object'));
34
39
  // glTF extensions a package may require, by world format (RFC-0004).
35
40
  export function sceneExtensions(version) {
36
41
  const base = ['KHR_materials_unlit','KHR_texture_transform','KHR_materials_emissive_strength'];
@@ -39,8 +44,8 @@ export function sceneExtensions(version) {
39
44
  // World 0.8 object asset rules that need the asset's glTF (RFC-0004 "world 0.8"):
40
45
  // referenced node names exist once, animated subtrees do not nest, and the
41
46
  // collision proxy has a mesh and lies outside every animated subtree.
42
- function validateObjectAsset(gltf, asset, fail) {
43
- const nodes = gltf.nodes || [], parents = new Map(), label = `objectAssets ${asset.id}`;
47
+ function validateObjectAsset(gltf, asset, fail, label = `objectAssets ${asset.id}`) {
48
+ const nodes = gltf.nodes || [], parents = new Map();
44
49
  nodes.forEach((n, i) => { for (const c of n.children || []) parents.set(c, i); });
45
50
  const find = name => {
46
51
  const hits = nodes.flatMap((n, i) => n.name === name ? [i] : []);
@@ -60,13 +65,9 @@ function validateObjectAsset(gltf, asset, fail) {
60
65
  }
61
66
  }
62
67
  }
63
- // scene.gltf rules that depend on the world format: extensionsRequired, and
64
- // lightmap textures (world.lightmaps[].texture) stay PNG in every v0 format.
65
- // With options.asset, `gltf` is that world 0.8 object asset's glTF instead: the
66
- // same extension rules, the asset rules above, and no lightmaps (assets are never baked).
67
- function validateScene(gltf, version, world, assetId) {
68
- const errors = [], fail = m => errors.push(m);
69
- if (!worldFormats.includes(version)) throw new Error(`Unknown world format: ${version}`);
68
+ // glTF extension rules shared by scene.gltf, 0.8 object assets and object manifests:
69
+ // extensionsRequired within the format's list, KTX2 and meshopt listed when used.
70
+ function sceneExtensionRules(gltf, version, fail) {
70
71
  const allowed = new Set(sceneExtensions(version)), required = new Set(gltf.extensionsRequired || []);
71
72
  for (const id of required) if (!allowed.has(id)) fail(`extensionsRequired: ${id} is not allowed in world ${version}`);
72
73
  const textures = gltf.textures || [], images = gltf.images || [];
@@ -81,10 +82,30 @@ function validateScene(gltf, version, world, assetId) {
81
82
  if ((gltf.bufferViews || []).some(v => v.extensions?.EXT_meshopt_compression) && !required.has('EXT_meshopt_compression'))
82
83
  fail(allowed.has('EXT_meshopt_compression') ? 'extensionsRequired: EXT_meshopt_compression must be listed when buffer views use it'
83
84
  : 'bufferViews: EXT_meshopt_compression needs world 0.7');
85
+ }
86
+ // scene.gltf rules that depend on the world format: extensionsRequired, and
87
+ // lightmap textures (world.lightmaps[].texture) stay PNG in every v0 format.
88
+ // With options.asset, `gltf` is that world 0.8 object asset's glTF instead: the
89
+ // same extension rules, the asset rules above, and no lightmaps (assets are never baked).
90
+ // World 0.9 inline assets may give the animations and proxy as traits, and a
91
+ // character trait needs a skin and its clips; dir and library assets are checked
92
+ // through their object.json (kind object).
93
+ function validateScene(gltf, version, world, assetId) {
94
+ const errors = [], fail = m => errors.push(m);
95
+ if (!worldFormats.includes(version)) throw new Error(`Unknown world format: ${version}`);
96
+ sceneExtensionRules(gltf, version, fail);
97
+ const textures = gltf.textures || [], images = gltf.images || [];
84
98
  if (assetId !== undefined) {
85
99
  const asset = atLeast(version, '0.8') ? (world?.objectAssets || []).find(a => a.id === assetId) : undefined;
100
+ const label = `objectAssets ${assetId}`, character = asset?.traits?.character;
86
101
  if (!asset) fail(`objectAssets: unknown asset ${assetId} for world ${version}`);
87
- else validateObjectAsset(gltf, asset, fail);
102
+ else if (asset.scene === undefined) fail(`${label}: not an inline glTF asset; check its object.json with kind object`);
103
+ else validateObjectAsset(gltf, { id: asset.id, animations: asset.animations ?? asset.traits?.decorative?.animations, collision: asset.collision ?? asset.traits?.collidable }, fail);
104
+ if (asset?.scene !== undefined && character) {
105
+ if (!(gltf.skins || []).length) fail(`${label}: traits.character: the glTF has no skin`);
106
+ const clips = new Set((gltf.animations || []).map(a => a.name));
107
+ for (const name of Object.keys(character.clips)) if (!clips.has(name)) fail(`${label}: traits.character: clip "${name}" not found in the glTF`);
108
+ }
88
109
  return { valid: errors.length === 0, errors };
89
110
  }
90
111
  for (const lightmap of world?.lightmaps || []) {
@@ -97,13 +118,575 @@ function validateScene(gltf, version, world, assetId) {
97
118
  return { valid: errors.length === 0, errors };
98
119
  }
99
120
 
121
+ // Object manifests, libraries and library release records (RFC-0011). Origin
122
+ // conventions: floor, wall and ceiling objects within 1 cm, a character's
123
+ // rest-pose feet within 5 cm. The manifest-only rules always run; options add the rules that need more than
124
+ // object.json: `gltf` (the parsed scene glTF, or a GLB's JSON chunk),
125
+ // `directory` (its directory name), `files` ([{path, size}] of the directory),
126
+ // `thumbnail` ({width, height, bytes}) and `measured` (values only a geometry
127
+ // decoder knows: exact rest `bounds`, `maxTextureSize`, `gpuBytes`).
128
+ // Directory and asset limits (RFC-0011 "Budgets and limits", provisional).
129
+ export const objectLimits = Object.freeze({
130
+ files: 64, directoryBytes: 64 * 1048576, fileBytes: 32 * 1048576,
131
+ triangles: 50000, characterTriangles: 16000, itemTriangles: 2000,
132
+ textureSize: 2048, characterTextureSize: 1024, gpuBytes: 48 * 1048576, itemGpuBytes: 1048576,
133
+ collisionTriangles: 2000, thumbnailSize: 256, thumbnailBytes: 262144,
134
+ libraryFiles: 4000, libraryBytes: 512 * 1048576, boundsTolerance: 0.01, characterOriginTolerance: 0.05, slotMargin: 0.5,
135
+ });
136
+ // Which traits may share an asset (RFC-0011 "Allowed combinations"; machine per RFC-0013).
137
+ const traitPairs = {
138
+ decorative: ['collidable', 'movable', 'seat', 'mover', 'machine'], collidable: ['decorative', 'movable', 'seat', 'machine'],
139
+ movable: ['decorative', 'collidable', 'seat', 'machine'], character: [], mover: ['decorative'],
140
+ seat: ['decorative', 'collidable', 'movable'], machine: ['decorative', 'collidable', 'movable'],
141
+ };
142
+ function reservedTraits(version) {
143
+ const reserved = { mount: 'reserved for props that hold a window (RFC-0011 P4)', interactive: 'reserved for use actions (RFC-0011 P4)' };
144
+ if (!atLeast(version, '0.10')) Object.assign(reserved, { mover: 'needs object format 0.10', machine: 'reserved for RFC-0013 live objects (object format 0.10)' });
145
+ return reserved;
146
+ }
147
+ const componentMax = { 5120: 127, 5121: 255, 5122: 32767, 5123: 65535 };
148
+ function nodeMatrix(n) {
149
+ if (n.matrix) return n.matrix.slice();
150
+ const [x, y, z, w] = n.rotation || [0, 0, 0, 1], [sx, sy, sz] = n.scale || [1, 1, 1], t = n.translation || [0, 0, 0];
151
+ return [(1 - 2 * (y * y + z * z)) * sx, 2 * (x * y + z * w) * sx, 2 * (x * z - y * w) * sx, 0,
152
+ 2 * (x * y - z * w) * sy, (1 - 2 * (x * x + z * z)) * sy, 2 * (y * z + x * w) * sy, 0,
153
+ 2 * (x * z + y * w) * sz, 2 * (y * z - x * w) * sz, (1 - 2 * (x * x + y * y)) * sz, 0, t[0], t[1], t[2], 1];
154
+ }
155
+ const mul = (a, b) => Array.from({ length: 16 }, (_, i) => { const c = i >> 2, r = i & 3; return a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3]; });
156
+ const apply = (m, p) => [0, 1, 2].map(r => m[r] * p[0] + m[4 + r] * p[1] + m[8 + r] * p[2] + m[12 + r]);
157
+ function primitiveTriangles(gltf, p) {
158
+ const count = gltf.accessors?.[p.indices ?? p.attributes?.POSITION]?.count || 0, mode = p.mode ?? 4;
159
+ return mode === 4 ? Math.floor(count / 3) : mode === 5 || mode === 6 ? Math.max(0, count - 2) : 0;
160
+ }
161
+ // The drawn scene graph: world matrix per reachable node (first visit wins; cycles stop).
162
+ function sceneNodes(gltf) {
163
+ const nodes = gltf.nodes || [], scene = gltf.scenes?.[gltf.scene ?? 0], out = new Map();
164
+ const visit = (i, parent, depth) => {
165
+ if (out.has(i) || !nodes[i] || depth > 64) return;
166
+ const m = mul(parent, nodeMatrix(nodes[i])); out.set(i, m);
167
+ for (const c of nodes[i].children || []) visit(c, m, depth + 1);
168
+ };
169
+ for (const i of scene?.nodes || []) visit(i, nodeMatrix({}), 0);
170
+ return { scene, out };
171
+ }
172
+ // Rest-pose bounds from accessor min/max: exact when every mesh's world matrix is
173
+ // axis-aligned, else a box that contains the true bounds. Skinned meshes: unknown.
174
+ function accessorBounds(gltf, drawn) {
175
+ const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
176
+ let exact = true, skinned = false;
177
+ for (const [i, m] of drawn) {
178
+ const node = gltf.nodes[i], mesh = gltf.meshes?.[node.mesh];
179
+ if (!mesh) continue;
180
+ if (node.skin !== undefined) { skinned = true; continue; }
181
+ for (let c = 0; c < 3; c++) if ([0, 1, 2].filter(r => Math.abs(m[c * 4 + r]) > 1e-6).length > 1) exact = false;
182
+ for (const p of mesh.primitives || []) {
183
+ const a = gltf.accessors?.[p.attributes?.POSITION];
184
+ if (!a?.min || !a?.max) { skinned = true; continue; }
185
+ const scale = a.normalized && [...a.min, ...a.max].some(v => Math.abs(v) > 1) ? componentMax[a.componentType] || 1 : 1;
186
+ const [mn, mx] = [a.min, a.max].map(v => v.map(x => Math.max(x / scale, a.normalized ? -1 : -Infinity)));
187
+ for (let k = 0; k < 8; k++) {
188
+ const w = apply(m, [k & 1 ? mx[0] : mn[0], k & 2 ? mx[1] : mn[1], k & 4 ? mx[2] : mn[2]]);
189
+ for (let j = 0; j < 3; j++) { lo[j] = Math.min(lo[j], w[j]); hi[j] = Math.max(hi[j], w[j]); }
190
+ }
191
+ }
192
+ }
193
+ return skinned || lo[0] === Infinity ? undefined : { min: lo, max: hi, exact };
194
+ }
195
+ function validateObjectGltf(value, version, gltf, options, fail, warn) {
196
+ const traits = value.traits, slots = value.slots || {}, nodes = gltf.nodes || [], materials = gltf.materials || [];
197
+ sceneExtensionRules(gltf, version, fail);
198
+ const { scene, out: drawn } = sceneNodes(gltf);
199
+ if (!scene) { fail('scene: the glTF has no scene'); return; }
200
+ const byName = new Map();
201
+ nodes.forEach((n, i) => { if (n.name !== undefined) byName.set(n.name, [...(byName.get(n.name) || []), i]); });
202
+ const node = (name, label, { inScene = true } = {}) => {
203
+ const hits = byName.get(name) || [];
204
+ if (hits.length !== 1) { fail(`${label}: node "${name}" ${hits.length ? 'is not unique' : 'not found'} in the glTF`); return undefined; }
205
+ if (inScene && !drawn.has(hits[0])) fail(`${label}: node "${name}" is not in the scene`);
206
+ return hits[0];
207
+ };
208
+ // The 0.8 asset rules: animation and collision node names, nesting, a static proxy mesh.
209
+ validateObjectAsset(gltf, { animations: traits.decorative?.animations, collision: traits.collidable }, fail, 'traits');
210
+ for (const a of traits.decorative?.animations || []) if ((byName.get(a.node) || []).length === 1 && !drawn.has(byName.get(a.node)[0])) fail(`traits.decorative: node "${a.node}" is not in the scene`);
211
+ // Slots: names exist once; a material or node belongs to one states slot; variants map to palette ids.
212
+ const materialIndex = name => {
213
+ const hits = materials.flatMap((m, i) => m.name === name ? [i] : []);
214
+ return hits.length === 1 ? hits[0] : undefined;
215
+ };
216
+ const owner = new Map(), slotPrimitives = new Map();
217
+ const subtree = i => { const seen = new Set(), stack = [i]; while (stack.length) { const n = stack.pop(); if (seen.has(n) || !nodes[n]) continue; seen.add(n); stack.push(...(nodes[n].children || [])); } return [...seen]; };
218
+ for (const slot of slots.states || []) {
219
+ const label = `slots.states ${slot.id}`, prims = [];
220
+ for (const name of slot.materials || []) {
221
+ const hits = materials.filter(m => m.name === name).length;
222
+ if (hits !== 1) { fail(`${label}: material "${name}" ${hits ? 'is not unique' : 'not found'} in the glTF`); continue; }
223
+ const i = materialIndex(name);
224
+ if (owner.has('m' + i)) fail(`${label}: material "${name}" is also in states slot ${owner.get('m' + i)}`); owner.set('m' + i, slot.id);
225
+ for (const [n] of drawn) for (const p of gltf.meshes?.[nodes[n].mesh]?.primitives || []) if (p.material === i) prims.push(p);
226
+ if (!prims.length) warn(`${label}: material "${name}" is not used by the scene`);
227
+ }
228
+ for (const name of slot.nodes || []) {
229
+ const i = node(name, label);
230
+ if (i === undefined) continue;
231
+ if (owner.has('n' + i)) fail(`${label}: node "${name}" is also in states slot ${owner.get('n' + i)}`); owner.set('n' + i, slot.id);
232
+ const meshes = subtree(i).filter(n => nodes[n].mesh !== undefined);
233
+ if (!meshes.length) fail(`${label}: node "${name}" has no mesh in its subtree`);
234
+ for (const n of meshes) prims.push(...(gltf.meshes?.[nodes[n].mesh]?.primitives || []));
235
+ }
236
+ slotPrimitives.set(slot.id, prims);
237
+ }
238
+ const variants = gltf.extensions?.KHR_materials_variants?.variants;
239
+ if (variants) {
240
+ const names = variants.map(v => v.name);
241
+ if (new Set(names).size !== names.length) fail('KHR_materials_variants: duplicate variant names');
242
+ (gltf.meshes || []).forEach((mesh, m) => (mesh.primitives || []).forEach((p, k) => {
243
+ const seen = new Set();
244
+ for (const mapping of p.extensions?.KHR_materials_variants?.mappings || []) {
245
+ if (!materials[mapping.material]) fail(`meshes/${m}/primitives/${k}: variant mapping to unknown material ${mapping.material}`);
246
+ for (const v of mapping.variants || []) {
247
+ if (!variants[v]) fail(`meshes/${m}/primitives/${k}: unknown variant ${v}`);
248
+ if (seen.has(v)) fail(`meshes/${m}/primitives/${k}: variant ${v} is mapped twice`); seen.add(v);
249
+ }
250
+ }
251
+ }));
252
+ for (const slot of slots.states || []) {
253
+ const palette = new Set(slot.palette.map(e => e.id));
254
+ for (const p of slotPrimitives.get(slot.id) || []) for (const mapping of p.extensions?.KHR_materials_variants?.mappings || [])
255
+ for (const v of mapping.variants || []) if (variants[v] && !palette.has(variants[v].name))
256
+ fail(`slots.states ${slot.id}: variant "${variants[v].name}" is not a palette id of the slot`);
257
+ }
258
+ }
259
+ for (const s of slots.stacks || []) node(s.node, `slots.stacks ${s.id}`);
260
+ for (const t of slots.toggles || []) for (const name of t.nodes) {
261
+ if (name === traits.collidable?.node) fail(`slots.toggles ${t.id}: the collision proxy can't be toggled`);
262
+ node(name, `slots.toggles ${t.id}`);
263
+ }
264
+ // Characters are skinned and carry their clips; other objects animate declaratively.
265
+ const clips = new Map((gltf.animations || []).map(a => [a.name, a]));
266
+ if (traits.character) {
267
+ if (!(gltf.skins || []).length) fail('traits.character: the glTF has no skin');
268
+ for (const [name, measured] of Object.entries(traits.character.clips)) {
269
+ const clip = clips.get(name);
270
+ if (!clip) { fail(`traits.character: clip "${name}" not found in the glTF`); continue; }
271
+ if (measured.speed !== undefined) {
272
+ const speed = clip.extras?.speed;
273
+ if (typeof speed !== 'number') fail(`traits.character: clip "${name}" has no extras.speed`);
274
+ else if (Math.abs(measured.speed - speed) > 0.02 * speed) fail(`traits.character: clip "${name}" speed ${measured.speed} differs from extras.speed ${speed} by more than 2%`);
275
+ }
276
+ }
277
+ } else {
278
+ if ((gltf.skins || []).length) fail('skins: only characters may be skinned');
279
+ if ((gltf.animations || []).length) warn('animations: glTF animations are ignored outside characters; use traits.decorative');
280
+ }
281
+ // Triangle budgets (drawn scene; proxy separately) and bounds.
282
+ let triangles = 0;
283
+ for (const [i] of drawn) for (const p of gltf.meshes?.[nodes[i].mesh]?.primitives || []) triangles += primitiveTriangles(gltf, p);
284
+ const item = traits.mover?.item === true;
285
+ const cap = traits.character ? objectLimits.characterTriangles : item ? objectLimits.itemTriangles : objectLimits.triangles;
286
+ if (triangles > cap) fail(`triangles: ${triangles} exceeds ${cap}`);
287
+ const proxy = traits.collidable && (byName.get(traits.collidable.node) || [])[0];
288
+ if (proxy !== undefined) {
289
+ const n = (gltf.meshes?.[nodes[proxy].mesh]?.primitives || []).reduce((s, p) => s + primitiveTriangles(gltf, p), 0);
290
+ if (n > objectLimits.collisionTriangles) fail(`traits.collidable: proxy has ${n} triangles, more than ${objectLimits.collisionTriangles}`);
291
+ }
292
+ if (!options.measured?.bounds) {
293
+ const b = accessorBounds(gltf, drawn), t = objectLimits.boundsTolerance;
294
+ if (!b) warn('bounds: not checked (skinned or unbounded geometry needs measured bounds)');
295
+ else for (let j = 0; j < 3; j++) {
296
+ const axis = 'xyz'[j];
297
+ if (b.exact ? Math.abs(value.bounds.min[j] - b.min[j]) > t : value.bounds.min[j] < b.min[j] - t) fail(`bounds: min ${axis} ${value.bounds.min[j]} does not match the scene (${+b.min[j].toFixed(4)})`);
298
+ if (b.exact ? Math.abs(value.bounds.max[j] - b.max[j]) > t : value.bounds.max[j] > b.max[j] + t) fail(`bounds: max ${axis} ${value.bounds.max[j]} does not match the scene (${+b.max[j].toFixed(4)})`);
299
+ }
300
+ if (b && !b.exact) warn('bounds: checked against a containing box only (rotated meshes); pass measured bounds for the 1 cm check');
301
+ }
302
+ return { triangles };
303
+ }
304
+ // Typed machine inputs, drives and recipes (RFC-0013, object format 0.10 draft).
305
+ function validateMachine(value, fail) {
306
+ const machine = value.traits.machine, slots = value.slots || {}, label = 'traits.machine';
307
+ const inputs = new Map();
308
+ for (const input of machine.inputs) {
309
+ if (inputs.has(input.id)) fail(`${label}: duplicate input ${input.id}`); inputs.set(input.id, input);
310
+ const d = input.default, il = `${label} input ${input.id}`;
311
+ if (['int', 'number'].includes(input.type)) {
312
+ if (input.min >= input.max) fail(`${il}: min must be below max`);
313
+ if (input.type === 'int' && ![input.min, input.max].every(Number.isInteger)) fail(`${il}: int bounds must be integers`);
314
+ }
315
+ if (d === undefined) continue;
316
+ const ok = { enum: () => input.values.includes(d), int: () => Number.isInteger(d) && d >= input.min && d <= input.max,
317
+ number: () => typeof d === 'number' && d >= input.min && d <= input.max, bool: () => typeof d === 'boolean',
318
+ text: () => typeof d === 'string' && d.length <= (input.maxLength ?? 64), event: () => false, items: () => false }[input.type];
319
+ if (!ok()) fail(`${il}: default ${JSON.stringify(d)} is not a valid ${input.type} value`);
320
+ }
321
+ const count = machine.inputs.reduce((n, i) => n + (i.repeat || 1), 0);
322
+ if (count > 16) fail(`${label}: ${count} inputs counting repeats, more than 16`);
323
+ if (machine.primary !== undefined && !inputs.has(machine.primary)) fail(`${label}: primary names unknown input ${machine.primary}`);
324
+ const resolve = (ref, where) => {
325
+ const [id, n] = ref.split('.'), input = inputs.get(id);
326
+ if (!input) { fail(`${where}: unknown input ${ref}`); return undefined; }
327
+ if (input.repeat ? n === undefined || Number(n) > input.repeat : n !== undefined) { fail(`${where}: ${ref} ${input.repeat ? `needs an index 1-${input.repeat}` : 'takes no index'}`); return undefined; }
328
+ return input;
329
+ };
330
+ // What a mapping can produce from an input: a set of scalars, a numeric range, or a kind.
331
+ const outputs = (map, input) => {
332
+ if (!map || !Object.keys(map).length) {
333
+ if (input.type === 'enum') return { values: input.values };
334
+ if (input.type === 'bool') return { values: [true, false] };
335
+ if (input.type === 'int' || input.type === 'number') return { range: [input.min, input.max], integer: input.type === 'int' };
336
+ return { kind: input.type };
337
+ }
338
+ if (map.cases || map.thresholds) return { values: [...(map.cases || map.thresholds).map(c => c.to), ...('else' in map ? [map.else] : [])] };
339
+ if (map.scale) return map.clamp === false ? { kind: 'unbounded' } : { range: [Math.min(...map.scale.to), Math.max(...map.scale.to)], integer: !!map.round };
340
+ if ('becomes' in map || map.counter) return { kind: 'event' };
341
+ if (map.format) return { kind: 'text' };
342
+ return { kind: 'unknown' };
343
+ };
344
+ const animated = new Set((value.traits.decorative?.animations || []).map(a => a.node));
345
+ const positions = new Set((machine.positions || []).map(p => p.node));
346
+ const slotIds = kind => new Set((slots[kind] || []).map(s => s.id));
347
+ const palettes = new Map((slots.states || []).map(s => [s.id, new Set(s.palette.map(e => e.id))]));
348
+ const within = (o, lo, hi, where) => {
349
+ if (o.values) { if (o.values.some(v => typeof v !== 'number' || v < lo || v > hi)) fail(`${where}: outputs must be numbers ${lo}-${hi}`); }
350
+ else if (o.range) { if (o.range[0] < lo || o.range[1] > hi) fail(`${where}: range ${o.range.join('-')} exceeds ${lo}-${hi}`); }
351
+ else fail(`${where}: needs a numeric mapping`);
352
+ };
353
+ for (const drive of machine.drives) {
354
+ const where = `${label} drive ${drive.input} -> ${drive.target}`, input = resolve(drive.input, where);
355
+ const [kind, name] = [drive.target.slice(0, drive.target.indexOf(':')), drive.target.slice(drive.target.indexOf(':') + 1)];
356
+ const known = { animation: () => animated.has(name), position: () => positions.has(name), state: () => palettes.has(name), emission: () => palettes.has(name),
357
+ label: () => slotIds('labels').has(name), visible: () => slotIds('toggles').has(name), count: () => slotIds('stacks').has(name),
358
+ spawn: () => slotIds('spawns').has(name), effect: () => slotIds('effects').has(name) }[kind];
359
+ if (!known()) { fail(`${where}: unknown ${kind} target "${name}"`); continue; }
360
+ if (!input) continue;
361
+ const o = outputs(drive.map, input), event = o.kind === 'event' || (input.type === 'event' && !drive.map);
362
+ if (kind === 'state') {
363
+ if (!o.values) fail(`${where}: needs palette ids (an enum input or cases)`);
364
+ else for (const v of o.values) if (!palettes.get(name).has(v)) fail(`${where}: "${v}" is not a palette id of ${name}`);
365
+ } else if (kind === 'animation') within(o, 0, 10, where);
366
+ else if (kind === 'emission') within(o, 0, 16, where);
367
+ else if (kind === 'position') within(o, 0, 1, where);
368
+ else if (kind === 'count') { within(o, 0, Infinity, where); if (o.range && !o.integer) fail(`${where}: counts need an int input or a rounded scale`); }
369
+ else if (kind === 'visible') { if (!o.values || o.values.some(v => typeof v !== 'boolean')) fail(`${where}: needs boolean outputs`); }
370
+ else if (kind === 'spawn' || kind === 'effect') { if (!event) fail(`${where}: needs an event (an event input, counter or becomes)`); }
371
+ else if (kind === 'label' && event) fail(`${where}: events can't be shown as text`);
372
+ }
373
+ for (const p of machine.positions || []) {
374
+ if (!animated.has(p.node)) fail(`${label}: position "${p.node}" is not a decorative animation`);
375
+ if (p.range[0] >= p.range[1]) fail(`${label}: position "${p.node}" range must increase`);
376
+ }
377
+ for (const recipe of machine.recipes || []) {
378
+ for (const ref of Object.keys(recipe.fields)) resolve(ref, `${label} recipe ${recipe.name}`);
379
+ for (const [ref, map] of Object.entries(recipe.maps || {})) {
380
+ const input = resolve(ref, `${label} recipe ${recipe.name}`);
381
+ if (input?.type === 'enum') for (const v of outputs(map, { type: 'text' }).values || [])
382
+ if (!input.values.includes(v)) fail(`${label} recipe ${recipe.name}: ${ref} maps to "${v}", not a value of the input`);
383
+ }
384
+ }
385
+ }
386
+ // The trait and slot rules that need no geometry: combinations, animation fields,
387
+ // a static proxy, movable scale, unique ids, anchor capacity, slot poses and palettes.
388
+ // Object manifests pass `inside` (poses within the grown bounds); world 0.9 inline
389
+ // assets have no bounds. Messages are relative to the manifest (traits..., slots...).
390
+ function traitCombinations(names, fail) {
391
+ for (const a of names) for (const c of names) if (a < c && !(traitPairs[a] || []).includes(c)) fail(`traits: ${a} and ${c} can't be combined`);
392
+ }
393
+ function objectTraitRules(traits, slots, fail, inside = () => {}) {
394
+ const quat = (q, where) => { if (Math.abs(Math.hypot(...q) - 1) > 0.002) fail(`${where}: quaternion must be normalized`); };
395
+ const unique = (items, field, label) => { const ids = items.map(v => v[field]); if (new Set(ids).size !== ids.length) fail(`${label}: duplicate ${field}`); };
396
+ // Traits.
397
+ const animations = traits.decorative?.animations || [];
398
+ unique(animations, 'node', 'traits.decorative animations');
399
+ for (const n of animations) {
400
+ if (Math.abs(Math.hypot(...n.axis) - 1) > 0.001) fail(`traits.decorative: animation "${n.node}" axis must be a unit vector`);
401
+ if (n.type === 'bob' ? n.amplitude === undefined : n.pivot === undefined) fail(`traits.decorative: ${n.type} animation "${n.node}" needs ${n.type === 'bob' ? 'an amplitude' : 'a pivot'}`);
402
+ if (n.type === 'bob' ? n.pivot !== undefined : n.amplitude !== undefined) fail(`traits.decorative: ${n.type} animation "${n.node}" takes no ${n.type === 'bob' ? 'pivot' : 'amplitude'}`);
403
+ }
404
+ if (traits.collidable && animations.some(n => n.node === traits.collidable.node)) fail(`traits.collidable: node "${traits.collidable.node}" is animated`);
405
+ const scale = traits.movable?.scale;
406
+ if (scale && !(scale[0] <= 1 && 1 <= scale[1])) fail('traits.movable: scale must be [min, max] with min <= 1 <= max');
407
+ if (traits.character) unique(traits.character.interactions || [], 'id', 'traits.character interactions');
408
+ if (traits.seat) {
409
+ unique(traits.seat.anchors, 'id', 'traits.seat anchors');
410
+ if ((traits.seat.capacity ?? 0) > traits.seat.anchors.length) fail('traits.seat: capacity exceeds the number of anchors');
411
+ for (const a of traits.seat.anchors) inside(a.position, `traits.seat anchor ${a.id}`);
412
+ }
413
+ if (traits.mover?.offset) inside(traits.mover.offset, 'traits.mover offset');
414
+ // Slots: ids unique per kind, poses inside the grown bounds, palettes with unique ids.
415
+ for (const [kind, list] of Object.entries(slots)) {
416
+ unique(list, 'id', `slots.${kind}`);
417
+ for (const s of list) {
418
+ if (s.pose) { quat(s.pose.rotation, `slots.${kind} ${s.id}`); inside(s.pose.position, `slots.${kind} ${s.id}`); }
419
+ if (s.palette) unique(s.palette, 'id', `slots.${kind} ${s.id} palette`);
420
+ }
421
+ }
422
+ const tracks = new Map((slots.tracks || []).map(tr => [tr.id, tr]));
423
+ for (const tr of slots.tracks || []) tr.points.forEach((p, i) => {
424
+ inside(p.position, `slots.tracks ${tr.id} point ${i}`);
425
+ const prev = tr.points[i - 1]?.position;
426
+ if (p.type === 'arc' && prev && Math.hypot(p.position[0] - prev[0], p.position[2] - prev[2]) < 0.1) fail(`slots.tracks ${tr.id} point ${i}: an arc needs a horizontal chord of at least 0.1 m`);
427
+ });
428
+ for (const s of slots.spawns || []) if (s.track !== undefined && s.track !== 'port') {
429
+ const tr = tracks.get(s.track);
430
+ if (!tr) fail(`slots.spawns ${s.id}: unknown track ${s.track}`);
431
+ else for (const a of s.items || []) if (tr.items && !tr.items.assets.includes(a)) fail(`slots.spawns ${s.id}: item ${a} is not in track ${tr.id}'s items`);
432
+ }
433
+ for (const s of slots.stacks || []) {
434
+ if (s.cap > s.layout.grid.reduce((x, y) => x * y, 1)) fail(`slots.stacks ${s.id}: cap exceeds the layout grid`);
435
+ s.layout.grid.forEach((n, j) => { if (n > 1 && !(s.layout.spacing[j] > 0)) fail(`slots.stacks ${s.id}: spacing ${'xyz'[j]} must be positive for ${n} cells`); });
436
+ }
437
+ }
438
+ function validateObjectManifest(value, version, options, fail, warn) {
439
+ const traits = value.traits, slots = value.slots || {}, names = Object.keys(traits), b = value.bounds;
440
+ if (options.directory !== undefined && options.directory !== value.id) fail(`id: "${value.id}" differs from its directory "${options.directory}"`);
441
+ traitCombinations(names, fail);
442
+ for (let j = 0; j < 3; j++) if (b.min[j] >= b.max[j]) fail(`bounds: min ${'xyz'[j]} must be below max`);
443
+ const t = objectLimits.boundsTolerance, surfaces = new Set(traits.movable ? traits.movable.surfaces || ['floor'] : []);
444
+ const one = surfaces.size === 1 ? [...surfaces][0] : undefined;
445
+ if (one === 'floor' && Math.abs(b.min[1]) > t) fail(`bounds: min y must be 0 (the base at the origin), is ${b.min[1]}`);
446
+ // A character's rest (bind) pose is not the pose it stands in, so its feet get a looser check.
447
+ if (traits.character && Math.abs(b.min[1]) > objectLimits.characterOriginTolerance) fail(`bounds: min y must be within ${objectLimits.characterOriginTolerance} m of 0 (the feet at the origin), is ${b.min[1]}`);
448
+ if (one === 'wall' && Math.abs(b.min[2]) > t) fail(`bounds: min z must be 0 (the back against the wall), is ${b.min[2]}`);
449
+ if (one === 'ceiling' && Math.abs(b.max[1]) > t) fail(`bounds: max y must be 0 (the top at the origin), is ${b.max[1]}`);
450
+ const m = objectLimits.slotMargin, inside = (p, where, grow = m) => {
451
+ if (p.some((v, j) => v < b.min[j] - grow || v > b.max[j] + grow)) fail(`${where}: lies outside the bounds grown by ${grow} m`);
452
+ };
453
+ objectTraitRules(traits, slots, fail, inside);
454
+ if (traits.machine) validateMachine(value, fail);
455
+ // Directory contents, thumbnail and measured values.
456
+ const files = options.files;
457
+ if (files) {
458
+ const total = files.reduce((n, f) => n + f.size, 0), paths = new Set(files.map(f => f.path));
459
+ if (files.length > objectLimits.files) fail(`directory: ${files.length} files, more than ${objectLimits.files}`);
460
+ if (total > objectLimits.directoryBytes) fail(`directory: ${total} bytes, more than ${objectLimits.directoryBytes}`);
461
+ for (const f of files) {
462
+ if (f.size > objectLimits.fileBytes) fail(`directory: ${f.path} exceeds ${objectLimits.fileBytes} bytes`);
463
+ if (!value.attribution.some(a => a.path === '.' || a.path === f.path || f.path.startsWith(a.path + '/'))) fail(`attribution: ${f.path} is not covered`);
464
+ }
465
+ for (const [field, path] of [['scene', value.scene], ['thumbnail', value.thumbnail]]) if (path !== undefined && !paths.has(path)) fail(`${field}: ${path} not found in the directory`);
466
+ }
467
+ if (options.thumbnail) {
468
+ const { width, height, bytes } = options.thumbnail, s = objectLimits.thumbnailSize;
469
+ if (width !== s || height !== s) fail(`thumbnail: ${width} x ${height}, must be ${s} x ${s}`);
470
+ if (bytes > objectLimits.thumbnailBytes) fail(`thumbnail: ${bytes} bytes, more than ${objectLimits.thumbnailBytes}`);
471
+ }
472
+ const measured = options.measured || {};
473
+ if (measured.bounds) for (const end of ['min', 'max']) for (let j = 0; j < 3; j++)
474
+ if (Math.abs(b[end][j] - measured.bounds[end][j]) > t) fail(`bounds: ${end} ${'xyz'[j]} ${b[end][j]} is more than 1 cm from the scene's ${+measured.bounds[end][j].toFixed(4)}`);
475
+ const texture = traits.character ? objectLimits.characterTextureSize : objectLimits.textureSize;
476
+ if (measured.maxTextureSize > texture) fail(`textures: ${measured.maxTextureSize} px exceeds ${texture} px`);
477
+ const gpu = traits.mover?.item ? objectLimits.itemGpuBytes : objectLimits.gpuBytes;
478
+ if (measured.gpuBytes > gpu) fail(`gpu: ${measured.gpuBytes} bytes exceeds ${gpu}`);
479
+ if (options.gltf) return validateObjectGltf(value, version, options.gltf, options, fail, warn);
480
+ return {};
481
+ }
482
+ // library.json: listed object directories, their formats and the library's own files.
483
+ function validateLibraryManifest(value, options, fail) {
484
+ const objects = options.objects;
485
+ if (objects) {
486
+ for (const id of value.objects) {
487
+ const o = objects[id];
488
+ if (!o) { fail(`objects: ${id} has no objects/${id}/object.json`); continue; }
489
+ if (o.id !== id) fail(`objects: objects/${id}/object.json has id ${o.id}`);
490
+ if (typeof o.formatVersion !== 'string' || !objectFormats.includes(o.formatVersion)) fail(`objects: ${id} has no known object formatVersion (${JSON.stringify(o.formatVersion)})`);
491
+ else if (!atLeast(value.formatVersion, o.formatVersion)) fail(`objects: ${id} uses object format ${o.formatVersion}, newer than the library's ${value.formatVersion}`);
492
+ }
493
+ for (const id of Object.keys(objects)) if (!value.objects.includes(id)) fail(`objects: objects/${id} is not listed`);
494
+ }
495
+ const files = options.files;
496
+ if (files) {
497
+ const total = files.reduce((n, f) => n + f.size, 0);
498
+ if (files.length > objectLimits.libraryFiles) fail(`library: ${files.length} files, more than ${objectLimits.libraryFiles}`);
499
+ if (total > objectLimits.libraryBytes) fail(`library: ${total} bytes, more than ${objectLimits.libraryBytes}`);
500
+ for (const required of ['library.json', 'LICENSE', 'ATTRIBUTION.md']) if (!files.some(f => f.path === required)) fail(`library: missing ${required}`);
501
+ for (const f of files) if (!f.path.startsWith('objects/') && !value.attribution.some(a => a.path === '.' || a.path === f.path || f.path.startsWith(a.path + '/'))) fail(`attribution: ${f.path} is not covered`);
502
+ }
503
+ }
504
+ // libraries/releases.json: one pinned archive per id@version.
505
+ function validateLibraryReleases(value, fail) {
506
+ for (const [key, r] of Object.entries(value)) {
507
+ if (key !== `${r.id}@${r.version}`) fail(`${key}: key must be id@version (${r.id}@${r.version})`);
508
+ if (!objectFormats.includes(r.formatVersion)) fail(`${key}: formatVersion ${r.formatVersion} is not a known object format`);
509
+ if (!r.url.endsWith(`/${r.id}-${r.version}.tar.zst`)) fail(`${key}: url must end with /${r.id}-${r.version}.tar.zst`);
510
+ if (btoa(String.fromCharCode(...r.sha256.match(/../g).map(h => parseInt(h, 16)))) !== r.nixHash.slice(7)) fail(`${key}: nixHash does not encode sha256`);
511
+ }
512
+ }
513
+
514
+ // Object and library manifests default to their own formatVersion; the release
515
+ // record to the newest directory that defines it. Results add `warnings`.
516
+ function validateManifestKind(kind, value, method, version, options) {
517
+ if (method) throw new Error('Method context is only valid for IPC');
518
+ const formats = kind === 'library-releases' ? versions.filter(v => has(v, kind)) : objectFormats;
519
+ version ??= kind === 'library-releases' ? formats.at(-1) : formats.includes(value?.formatVersion) ? value.formatVersion : formats.at(-1);
520
+ if (!formats.includes(version)) throw new Error(`Unknown ${kind} format: ${version}`);
521
+ const key = `${version}:${kind}:`;
522
+ if (!validators.has(key)) validators.set(key, ajv.getSchema(`https://hyprune.com/schema/v${version}/${kind}.schema.json`));
523
+ const check = validators.get(key), errors = [], warnings = [], fail = m => errors.push(m), warn = m => warnings.push(m);
524
+ // Name reserved traits and unknown formats plainly; the schema alone would only say
525
+ // "must NOT be valid" or "must be equal to constant".
526
+ const reserved = kind === 'object' && value?.traits && typeof value.traits === 'object'
527
+ ? Object.entries(reservedTraits(version)).filter(([name]) => Object.hasOwn(value.traits, name)) : [];
528
+ for (const [name, why] of reserved) fail(`traits.${name}: ${why}; rejected in object format ${version}`);
529
+ if (kind !== 'library-releases' && value?.formatVersion !== version)
530
+ fail(`formatVersion: ${JSON.stringify(value?.formatVersion)} is not ${formats.includes(value?.formatVersion) ? `the requested ${version}` : `an object format (${formats.join(', ')})`}`);
531
+ if (!check(value)) {
532
+ const said = new Set(reserved.map(([name]) => `/traits/${name}`));
533
+ const schemaErrors = check.errors.filter(e => !(e.keyword === 'not' && said.has(e.instancePath)) && !(e.keyword === 'const' && e.instancePath === '/formatVersion'));
534
+ return { valid: false, errors: [...errors, ...schemaErrors.map(e => `${e.instancePath || '/'} ${e.message}`)], warnings };
535
+ }
536
+ if (errors.length) return { valid: false, errors, warnings };
537
+ const spdx = (text, label) => { try { parseSpdx(text); } catch { fail(`${label}: invalid SPDX expression`); } };
538
+ if (kind !== 'library-releases') {
539
+ const prerelease = value.version.split('+')[0].split('-').slice(1).join('-');
540
+ if (prerelease.split('.').some(p => /^0\d+$/.test(p))) fail('version: numeric prerelease identifiers cannot have leading zeros');
541
+ spdx(value.license, 'license');
542
+ const paths = value.attribution.map(a => a.path);
543
+ if (new Set(paths).size !== paths.length) fail('attribution: duplicate path');
544
+ for (const a of value.attribution) spdx(a.license, `attribution ${a.path}`);
545
+ }
546
+ let stats = {};
547
+ if (kind === 'object') stats = validateObjectManifest(value, version, options, fail, warn) || {};
548
+ if (kind === 'library') validateLibraryManifest(value, options, fail);
549
+ if (kind === 'library-releases') validateLibraryReleases(value, fail);
550
+ return { valid: errors.length === 0, errors, warnings, ...stats };
551
+ }
552
+ // World 0.9 (RFC-0011 P2): object asset sources, characters, paths, seats and
553
+ // keepClear. `supplied` maps a dir or library asset id to its object.json, so its
554
+ // traits (character, seat, movable) can be checked like an inline asset's.
555
+ const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/;
556
+ const REVERSE_DOMAIN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
557
+ export const worldLimits = Object.freeze({ characters: 16, charactersPerPath: 8, pathPoints: 1024, arcChord: 0.1 });
558
+ function validateWorldObjects(value, supplied, fail, unique, quat) {
559
+ const format = value.formatVersion, assets = value.objectAssets || [], objects = value.objects || [];
560
+ const paths = value.paths || [], seats = value.seats || [], keepClear = value.keepClear || [];
561
+ unique(paths, 'id', 'paths'); unique(seats, 'id', 'seats'); unique(keepClear, 'id', 'keepClear');
562
+ // Traits per asset id, when known: inline traits (with the 0.8 shorthand), or a supplied object.json.
563
+ const known = new Map();
564
+ for (const a of assets) {
565
+ const label = `objectAssets ${a.id}`, f = m => fail(`${label}: ${m}`);
566
+ if (a.scene !== undefined) {
567
+ const traits = { ...(a.traits || {}) };
568
+ if (a.animations && a.traits?.decorative) f('animations is shorthand for traits.decorative; give one form');
569
+ if (a.collision && a.traits?.collidable) f('collision is shorthand for traits.collidable; give one form');
570
+ if (a.animations) traits.decorative = { animations: a.animations };
571
+ if (a.collision) traits.collidable = a.collision;
572
+ traitCombinations(Object.keys(traits), f);
573
+ objectTraitRules(a.traits || {}, a.slots || {}, f);
574
+ // The proxy from one form, the animations from the other.
575
+ const crossed = (a.collision && a.traits?.decorative) || (a.traits?.collidable && a.animations);
576
+ if (crossed && traits.decorative.animations.some(n => n.node === traits.collidable.node)) f(`collision node "${traits.collidable.node}" is animated`);
577
+ known.set(a.id, traits);
578
+ continue;
579
+ }
580
+ if (a.library !== undefined) {
581
+ const [id, version] = a.library.split('@');
582
+ if (!REVERSE_DOMAIN.test(id) || !SEMVER.test(version)) f(`library must be <reverse-domain id>@<semver>, is ${a.library}`);
583
+ }
584
+ const manifest = Object.hasOwn(supplied, a.id) ? supplied[a.id] : undefined;
585
+ if (manifest === undefined) continue;
586
+ if (!manifest || typeof manifest.traits !== 'object' || manifest.traits === null) { f('the supplied object.json has no traits'); continue; }
587
+ if (typeof manifest.formatVersion === 'string' && /^\d+\.\d+$/.test(manifest.formatVersion) && !atLeast(format, manifest.formatVersion))
588
+ f(`object format ${manifest.formatVersion} is newer than the world's ${format}`);
589
+ if (a.library !== undefined && manifest.id !== undefined && manifest.id !== a.object) f(`the supplied object.json is ${manifest.id}, not ${a.object}`);
590
+ known.set(a.id, manifest.traits);
591
+ }
592
+ const objectsById = new Map(objects.map(o => [o.id, o])), pathsById = new Map(paths.map(p => [p.id, p]));
593
+ const seatIds = new Set(seats.map(s => s.id)), zoneIds = new Set((value.zones || []).map(z => z.id)), anchorIds = new Set((value.anchors || []).map(a => a.id));
594
+ // A world seat id, else <objectId>[/<anchorId>]; anchors are checkable when the seat's asset is known.
595
+ const seatRef = (ref, where) => {
596
+ const [objectId, anchor] = ref.split('/');
597
+ if (anchor === undefined && seatIds.has(objectId)) return;
598
+ const target = objectsById.get(objectId);
599
+ if (!target) { fail(`${where}: unknown seat ${ref} (not a seats[] id or an object)`); return; }
600
+ const traits = known.get(target.asset);
601
+ if (!traits) return;
602
+ if (!traits.seat) fail(`${where}: object ${objectId} is not a seat`);
603
+ else if (anchor !== undefined && !traits.seat.anchors.some(x => x.id === anchor)) fail(`${where}: seat ${objectId} has no anchor ${anchor}`);
604
+ };
605
+ const objectTarget = (target, where) => {
606
+ if (target.anchor !== undefined && !anchorIds.has(target.anchor)) fail(`${where}: unknown anchor ${target.anchor}`);
607
+ if (target.object !== undefined && !objectsById.has(target.object)) fail(`${where}: unknown object ${target.object}`);
608
+ if (target.zoneId !== undefined && !zoneIds.has(target.zoneId)) fail(`${where}: unknown zone ${target.zoneId}`);
609
+ };
610
+ const route = (id, where) => {
611
+ const p = pathsById.get(id);
612
+ if (!p) fail(`${where}: unknown path ${id}`);
613
+ else if ((p.use ?? 'generic') !== 'route') fail(`${where}: path ${id} is not a route (use ${p.use ?? 'generic'})`);
614
+ };
615
+ let characters = 0;
616
+ const perPath = new Map();
617
+ for (const o of objects) {
618
+ const label = `objects ${o.id}`, traits = known.get(o.asset), character = traits ? !!traits.character : undefined;
619
+ if (o.path === undefined && o.pose === undefined) fail(`${label}: pose is required unless path is set`);
620
+ if (o.path !== undefined && o.pose !== undefined) fail(`${label}: pose and path are exclusive (a bound character starts at its phase along the route)`);
621
+ if (o.reverse !== undefined && o.path === undefined) fail(`${label}: reverse needs a path`);
622
+ if (o.seat !== undefined && o.path !== undefined) fail(`${label}: seat is for characters without a path (routes sit at their stops)`);
623
+ if (character === false) for (const field of ['path', 'seat', 'accent', 'interactions'])
624
+ if (o[field] !== undefined) fail(`${label}: ${field} is for characters; asset ${o.asset} is not a character`);
625
+ if (character && o.scale !== undefined && o.scale !== 1) fail(`${label}: a character's scale must be 1`);
626
+ if (o.locked !== undefined && traits && !traits.movable) fail(`${label}: locked is for movable assets; asset ${o.asset} is not movable`);
627
+ if (character || (character === undefined && o.path !== undefined)) characters++;
628
+ if (o.path !== undefined) { route(o.path, label); perPath.set(o.path, (perPath.get(o.path) || 0) + 1); }
629
+ if (o.seat !== undefined) seatRef(o.seat, `${label} seat`);
630
+ unique(o.interactions || [], 'id', `${label} interactions`);
631
+ for (const i of o.interactions || []) {
632
+ const where = `${label} interaction ${i.id}`;
633
+ if (i.action.type === 'guide') route(i.action.path, where);
634
+ if (i.action.type === 'waypoint') objectTarget(i.action.target, where);
635
+ }
636
+ }
637
+ if (characters > worldLimits.characters) fail(`objects: ${characters} character instances, more than ${worldLimits.characters}`);
638
+ for (const [id, n] of perPath) if (n > worldLimits.charactersPerPath) fail(`paths ${id}: ${n} characters, more than ${worldLimits.charactersPerPath}`);
639
+ let total = 0;
640
+ for (const p of paths) {
641
+ const label = `paths ${p.id}`, n = p.points.length;
642
+ total += n;
643
+ // The point's type is the arriving segment; an open path ignores the first point's.
644
+ p.points.forEach((point, i) => {
645
+ if (point.type !== 'arc' || (i === 0 && !p.closed)) return;
646
+ const prev = p.points[i === 0 ? n - 1 : i - 1].position;
647
+ if (Math.hypot(point.position[0] - prev[0], point.position[2] - prev[2]) < worldLimits.arcChord) fail(`${label} point ${i}: an arc needs a horizontal chord of at least ${worldLimits.arcChord} m`);
648
+ });
649
+ const stopped = new Set();
650
+ for (const stop of p.stops || []) {
651
+ const where = `${label} stop ${stop.point}`;
652
+ if (stop.point >= n) fail(`${where}: the path has ${n} points`);
653
+ if (stopped.has(stop.point)) fail(`${label}: more than one stop at point ${stop.point}`);
654
+ stopped.add(stop.point);
655
+ if (stop.seat !== undefined) seatRef(stop.seat, where);
656
+ if (stop.face) objectTarget(stop.face, where);
657
+ }
658
+ }
659
+ if (total > worldLimits.pathPoints) fail(`paths: ${total} points in total, more than ${worldLimits.pathPoints}`);
660
+ for (const seat of seats) quat(seat.pose.rotation, `seats ${seat.id}`);
661
+ for (const box of keepClear) if (box.min.some((v, i) => v >= box.max[i])) fail(`keepClear ${box.id}: min must be below max on every axis`);
662
+ }
663
+ // Plain reasons for names world 0.9 reserves; the schema alone reports them as
664
+ // unmatched alternatives. Schema errors under each path are replaced by the reason.
665
+ function worldReserved(value, version) {
666
+ if (!atLeast(version, '0.9') || !value || typeof value !== 'object') return [];
667
+ const out = [], list = v => Array.isArray(v) ? v : [];
668
+ const traitWhy = { mount: 'reserved for props that hold a window (RFC-0011 P4)', interactive: 'reserved for use actions (RFC-0011 P4)',
669
+ mover: 'needs world format 0.10', machine: 'reserved for RFC-0013 live objects' };
670
+ list(value.objectAssets).forEach((a, i) => {
671
+ if (a?.traits && typeof a.traits === 'object') for (const [name, why] of Object.entries(traitWhy))
672
+ if (Object.hasOwn(a.traits, name)) out.push([`/objectAssets/${i}`, `objectAssets ${a.id}: traits.${name}: ${why}; rejected in world ${version}`]);
673
+ });
674
+ list(value.objects).forEach((o, i) => list(o?.interactions).forEach((x, j) => {
675
+ const type = x?.action?.type;
676
+ if (type === 'travel' || type === 'quest') out.push([`/objects/${i}/interactions/${j}/action`,
677
+ `objects ${o.id} interaction ${x.id}: ${type} is reserved (${type === 'quest' ? 'RFC-0012 quests' : 'player travel'}); rejected in world ${version}`]);
678
+ }));
679
+ return out;
680
+ }
100
681
  // The optional method binds a response to the caller's outstanding request.
101
682
  // An envelope alone cannot know whether a valid result belongs to that method.
102
- export function validate(kind, value, method, version = '0.1', options = {}) {
683
+ export function validate(kind, value, method, version, options = {}) {
103
684
  if (kind === 'scene') {
104
685
  if (method) throw new Error('Method context is only valid for IPC');
105
- return validateScene(value, version, options.world, options.asset);
686
+ return validateScene(value, version ?? '0.1', options.world, options.asset);
106
687
  }
688
+ if (manifestKinds.includes(kind)) return validateManifestKind(kind, value, method, version, options);
689
+ version ??= '0.1';
107
690
  if (!names.includes(kind) || kind === 'common') throw new Error(`Unknown schema: ${kind}`);
108
691
  if (!contractVersions.includes(version)) throw new Error(`Unknown contract: ${version}`);
109
692
  const key = `${version}:${kind}:${method || ''}`;
@@ -118,7 +701,11 @@ export function validate(kind, value, method, version = '0.1', options = {}) {
118
701
  validators.set(key, ajv.getSchema(schema));
119
702
  }
120
703
  const check = validators.get(key);
121
- if (!check(value)) return { valid: false, errors: check.errors.map(e => `${e.instancePath || '/'} ${e.message}`) };
704
+ if (!check(value)) {
705
+ const reserved = kind === 'world' && version !== '0.1' ? worldReserved(value, version) : [];
706
+ const said = e => reserved.some(([path]) => e.instancePath === path || e.instancePath.startsWith(path + '/'));
707
+ return { valid: false, errors: [...reserved.map(([, message]) => message), ...check.errors.filter(e => !said(e)).map(e => `${e.instancePath || '/'} ${e.message}`)] };
708
+ }
122
709
  const errors = [];
123
710
  const fail = message => errors.push(message);
124
711
  const unique = (items, field, label) => {
@@ -210,7 +797,7 @@ export function validate(kind, value, method, version = '0.1', options = {}) {
210
797
  const ids = new Set(assets.map(a => a.id));
211
798
  for (const a of assets) {
212
799
  const label = `objectAssets ${a.id}`, animations = a.animations || [];
213
- if (a.library !== undefined) fail(`${label}: library references are reserved for a later world format; use a package scene`);
800
+ if (a.library !== undefined && !atLeast(value.formatVersion, '0.9')) fail(`${label}: library references are reserved for a later world format; use a package scene`);
214
801
  unique(animations, 'node', `${label} animations`);
215
802
  for (const n of animations) {
216
803
  if (Math.abs(Math.hypot(...n.axis) - 1) > 0.001) fail(`${label}: animation "${n.node}" axis must be a unit vector`);
@@ -221,9 +808,10 @@ export function validate(kind, value, method, version = '0.1', options = {}) {
221
808
  }
222
809
  for (const o of objects) {
223
810
  if (!ids.has(o.asset)) fail(`objects ${o.id}: unknown asset ${o.asset}`);
224
- quat(o.pose.rotation, `objects ${o.id}`);
811
+ if (o.pose) quat(o.pose.rotation, `objects ${o.id}`);
225
812
  }
226
813
  }
814
+ if (atLeast(value.formatVersion, '0.9')) validateWorldObjects(value, options.assets || {}, fail, unique, quat);
227
815
  unique(value.attribution, 'path', 'attribution');
228
816
  for (const a of value.attribution) license(a.license, `attribution ${a.path}`);
229
817
  }