@vulfram/engine 0.5.8-alpha → 0.17.1-alpha

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.
Files changed (58) hide show
  1. package/README.md +106 -0
  2. package/package.json +55 -4
  3. package/src/core.ts +14 -0
  4. package/src/ecs.ts +1 -0
  5. package/src/engine/api.ts +234 -23
  6. package/src/engine/bridge/dispatch.ts +265 -40
  7. package/src/engine/bridge/guards.ts +4 -1
  8. package/src/engine/bridge/protocol.ts +72 -54
  9. package/src/engine/ecs/index.ts +187 -42
  10. package/src/engine/state.ts +133 -2
  11. package/src/engine/systems/command-intent.ts +153 -3
  12. package/src/engine/systems/constraint-solve.ts +167 -0
  13. package/src/engine/systems/core-command-builder.ts +9 -265
  14. package/src/engine/systems/diagnostics.ts +20 -19
  15. package/src/engine/systems/index.ts +3 -1
  16. package/src/engine/systems/input-mirror.ts +101 -3
  17. package/src/engine/systems/resource-upload.ts +96 -44
  18. package/src/engine/systems/response-decode.ts +69 -15
  19. package/src/engine/systems/scene-sync.ts +306 -0
  20. package/src/engine/systems/ui-bridge.ts +360 -0
  21. package/src/engine/systems/utils.ts +43 -1
  22. package/src/engine/systems/world-lifecycle.ts +72 -103
  23. package/src/engine/window/manager.ts +168 -0
  24. package/src/engine/world/entities.ts +931 -33
  25. package/src/engine/world/mount.ts +174 -0
  26. package/src/engine/world/types.ts +71 -0
  27. package/src/engine/world/world-ui.ts +266 -0
  28. package/src/engine/world/world3d.ts +280 -0
  29. package/src/index.ts +30 -1
  30. package/src/mount.ts +2 -0
  31. package/src/types/cmds/audio.ts +189 -0
  32. package/src/types/cmds/camera.ts +18 -13
  33. package/src/types/cmds/environment.ts +47 -4
  34. package/src/types/cmds/geometry.ts +18 -16
  35. package/src/types/cmds/index.ts +203 -132
  36. package/src/types/cmds/light.ts +17 -13
  37. package/src/types/cmds/material.ts +14 -13
  38. package/src/types/cmds/model.ts +40 -16
  39. package/src/types/cmds/realm.ts +25 -0
  40. package/src/types/cmds/render-graph.ts +49 -0
  41. package/src/types/cmds/resources.ts +4 -0
  42. package/src/types/cmds/shadow.ts +7 -7
  43. package/src/types/cmds/system.ts +29 -0
  44. package/src/types/cmds/target.ts +82 -0
  45. package/src/types/cmds/texture.ts +19 -5
  46. package/src/types/cmds/ui.ts +220 -0
  47. package/src/types/cmds/window.ts +41 -204
  48. package/src/types/events/index.ts +4 -1
  49. package/src/types/events/pointer.ts +42 -13
  50. package/src/types/events/system.ts +150 -7
  51. package/src/types/events/ui.ts +21 -0
  52. package/src/types/index.ts +1 -0
  53. package/src/types/json.ts +15 -0
  54. package/src/types/kinds.ts +3 -0
  55. package/src/window.ts +8 -0
  56. package/src/world-ui.ts +2 -0
  57. package/src/world3d.ts +10 -0
  58. package/tsconfig.json +0 -29
@@ -1,10 +1,66 @@
1
1
  import type { ShadowConfig } from '../../types/cmds/shadow';
2
2
  import type { EnvironmentConfig } from '../../types/cmds/environment';
3
- import type { NotificationLevel, UserAttentionType } from '../../types/kinds';
3
+ import type {
4
+ CmdEnvironmentDisposeArgs,
5
+ } from '../../types/cmds/environment';
6
+ import type {
7
+ CmdTargetDisposeArgs,
8
+ CmdTargetLayerDisposeArgs,
9
+ CmdTargetLayerUpsertArgs,
10
+ CmdTargetUpsertArgs,
11
+ TargetLayerLayout,
12
+ } from '../../types/cmds/target';
13
+ import type {
14
+ CmdAudioListenerCreateArgs,
15
+ CmdAudioListenerDisposeArgs,
16
+ CmdAudioListenerUpsertArgs,
17
+ CmdAudioListenerUpdateArgs,
18
+ CmdAudioResourceUpsertArgs,
19
+ CmdAudioResourceDisposeArgs,
20
+ CmdAudioSourceTransportArgs,
21
+ CmdAudioSourceCreateArgs,
22
+ CmdAudioSourceDisposeArgs,
23
+ CmdAudioSourceUpsertArgs,
24
+ CmdAudioSourceUpdateArgs,
25
+ CmdAudioStateGetArgs,
26
+ } from '../../types/cmds/audio';
27
+ import type { CmdPoseUpdateArgs } from '../../types/cmds/model';
28
+ import type {
29
+ CmdSystemDiagnosticsSetArgs,
30
+ CmdUploadBufferDiscardAllArgs,
31
+ } from '../../types/cmds/system';
32
+ import type { CmdTextureBindTargetArgs } from '../../types/cmds/texture';
33
+ import type {
34
+ CmdUiAccessKitActionRequestArgs,
35
+ CmdUiApplyOpsArgs,
36
+ CmdUiClipboardPasteArgs,
37
+ CmdUiDebugSetArgs,
38
+ CmdUiDocumentCreateArgs,
39
+ CmdUiDocumentDisposeArgs,
40
+ CmdUiDocumentGetLayoutRectsArgs,
41
+ CmdUiDocumentGetTreeArgs,
42
+ CmdUiDocumentSetRectArgs,
43
+ CmdUiDocumentSetThemeArgs,
44
+ CmdUiEventTraceSetArgs,
45
+ CmdUiFocusGetArgs,
46
+ CmdUiFocusSetArgs,
47
+ CmdUiImageCreateFromBufferArgs,
48
+ CmdUiImageDisposeArgs,
49
+ CmdUiScreenshotReplyArgs,
50
+ CmdUiThemeDefineArgs,
51
+ CmdUiThemeDisposeArgs,
52
+ } from '../../types/cmds/ui';
53
+ import type { NotificationLevel } from '../../types/kinds';
54
+ import { EngineError } from '../errors';
4
55
  import { getWorldOrThrow, requireInitialized } from '../bridge/guards';
56
+ import {
57
+ enqueueCommand,
58
+ enqueueGlobalCommand,
59
+ markRoutingIndexDirty,
60
+ } from '../bridge/dispatch';
5
61
  import type {
62
+ CameraComponent,
6
63
  CameraProps,
7
- CreateWindowProps,
8
64
  GeometryProps,
9
65
  InputStateComponent,
10
66
  Intent,
@@ -14,10 +70,35 @@ import type {
14
70
  TagProps,
15
71
  TextureProps,
16
72
  TransformProps,
17
- WindowProps,
73
+ UiFocusCycleMode,
18
74
  WindowStateComponent,
19
75
  } from '../ecs';
20
76
  import { engineState } from '../state';
77
+ import type { GamepadEvent, SystemEvent, UiEvent } from '../../types/events';
78
+
79
+ function allocateGlobalId(): number {
80
+ return engineState.nextGlobalId++;
81
+ }
82
+
83
+ function recalculateWorldWindowBindings(world: ReturnType<typeof getWorldOrThrow>): void {
84
+ world.boundWindowIds.clear();
85
+ for (const windowId of world.targetWindowBindings.values()) {
86
+ world.boundWindowIds.add(windowId);
87
+ }
88
+
89
+ if (world.boundWindowIds.size === 0) {
90
+ world.primaryWindowId = undefined;
91
+ return;
92
+ }
93
+
94
+ let primary = Number.POSITIVE_INFINITY;
95
+ for (const windowId of world.boundWindowIds) {
96
+ if (windowId < primary) {
97
+ primary = windowId;
98
+ }
99
+ }
100
+ world.primaryWindowId = primary;
101
+ }
21
102
 
22
103
  /**
23
104
  * Emits an intent to the specified world.
@@ -30,56 +111,459 @@ export function emitIntent(worldId: number, intent: Intent): void {
30
111
  }
31
112
 
32
113
  /**
33
- * Emits an intent to create a window in the core.
114
+ * Returns the core model ID for an entity, if available.
34
115
  */
35
- export function createWindow(worldId: number, props: CreateWindowProps): void {
36
- emitIntent(worldId, {
37
- type: 'create-window',
38
- props,
116
+ export function getModelId(worldId: number, entityId: number): number | null {
117
+ requireInitialized();
118
+ const world = getWorldOrThrow(worldId);
119
+ const store = world.components.get(entityId);
120
+ if (!store) return null;
121
+ const model = store.get('Model') as { id: number } | undefined;
122
+ return model?.id ?? null;
123
+ }
124
+
125
+ /**
126
+ * Returns the core realm id associated with this world, if already created.
127
+ */
128
+ export function getWorldRealmId(worldId: number): number | null {
129
+ requireInitialized();
130
+ const world = getWorldOrThrow(worldId);
131
+ return world.coreRealmId ?? null;
132
+ }
133
+
134
+ /**
135
+ * Returns true when the world has a resolved core realm id.
136
+ */
137
+ export function isWorldReady(worldId: number): boolean {
138
+ return getWorldRealmId(worldId) !== null;
139
+ }
140
+
141
+ /**
142
+ * Upserts a target used to present world output.
143
+ */
144
+ export function upsertTarget(worldId: number, args: CmdTargetUpsertArgs): number {
145
+ const id = enqueueCommand(worldId, 'cmd-target-upsert', args);
146
+ const world = getWorldOrThrow(worldId);
147
+ if (args.kind === 'window' && args.windowId !== undefined) {
148
+ world.targetWindowBindings.set(args.targetId, args.windowId);
149
+ recalculateWorldWindowBindings(world);
150
+ markRoutingIndexDirty();
151
+ } else {
152
+ if (world.targetWindowBindings.delete(args.targetId)) {
153
+ recalculateWorldWindowBindings(world);
154
+ }
155
+ markRoutingIndexDirty();
156
+ }
157
+ return id;
158
+ }
159
+
160
+ /**
161
+ * Disposes a target.
162
+ */
163
+ export function disposeTarget(worldId: number, args: CmdTargetDisposeArgs): number {
164
+ const world = getWorldOrThrow(worldId);
165
+ world.targetLayerBindings.delete(args.targetId);
166
+ if (world.targetWindowBindings.delete(args.targetId)) {
167
+ recalculateWorldWindowBindings(world);
168
+ }
169
+ markRoutingIndexDirty();
170
+ return enqueueCommand(worldId, 'cmd-target-dispose', args);
171
+ }
172
+
173
+ /**
174
+ * Binds this world's realm to a target layer.
175
+ */
176
+ export function bindWorldToTarget(
177
+ worldId: number,
178
+ args: Omit<CmdTargetLayerUpsertArgs, 'realmId'>,
179
+ ): number {
180
+ const world = getWorldOrThrow(worldId);
181
+ const realmId = getWorldRealmId(worldId);
182
+ if (realmId === null) {
183
+ throw new EngineError(
184
+ 'WorldNotReady',
185
+ `World ${worldId} realm is not ready. Call tick() and wait for realm-create response.`,
186
+ );
187
+ }
188
+
189
+ const resolvedCameraId = args.cameraId ?? findPreferredCameraId(worldId);
190
+ const resolvedArgs: Omit<CmdTargetLayerUpsertArgs, 'realmId'> = {
191
+ ...args,
192
+ cameraId: resolvedCameraId,
193
+ };
194
+
195
+ world.targetLayerBindings.set(args.targetId, {
196
+ targetId: args.targetId,
197
+ layout: args.layout,
198
+ cameraId: resolvedCameraId,
199
+ environmentId: args.environmentId,
200
+ });
201
+ markRoutingIndexDirty();
202
+
203
+ return enqueueCommand(worldId, 'cmd-target-layer-upsert', {
204
+ realmId,
205
+ ...resolvedArgs,
39
206
  });
40
207
  }
41
208
 
42
209
  /**
43
- * Emits an intent to close the window associated with this world.
210
+ * Unbinds this world's realm from a target layer.
44
211
  */
45
- export function closeWindow(worldId: number): void {
46
- emitIntent(worldId, {
47
- type: 'close-window',
212
+ export function unbindWorldFromTarget(
213
+ worldId: number,
214
+ args: Omit<CmdTargetLayerDisposeArgs, 'realmId'>,
215
+ ): number {
216
+ const world = getWorldOrThrow(worldId);
217
+ const realmId = getWorldRealmId(worldId);
218
+ if (realmId === null) {
219
+ throw new EngineError(
220
+ 'WorldNotReady',
221
+ `World ${worldId} realm is not ready. Call tick() and wait for realm-create response.`,
222
+ );
223
+ }
224
+ world.targetLayerBindings.delete(args.targetId);
225
+ if (world.targetWindowBindings.delete(args.targetId)) {
226
+ recalculateWorldWindowBindings(world);
227
+ }
228
+ markRoutingIndexDirty();
229
+ return enqueueCommand(worldId, 'cmd-target-layer-dispose', {
230
+ realmId,
231
+ ...args,
48
232
  });
49
233
  }
50
234
 
235
+ function findPreferredCameraId(worldId: number): number | undefined {
236
+ const world = getWorldOrThrow(worldId);
237
+ let bestId: number | undefined;
238
+ let bestOrder = Number.POSITIVE_INFINITY;
239
+ for (const store of world.components.values()) {
240
+ const camera = store.get('Camera') as CameraComponent | undefined;
241
+ if (!camera) continue;
242
+ if (
243
+ camera.order < bestOrder ||
244
+ (camera.order === bestOrder &&
245
+ (bestId === undefined || camera.id < bestId))
246
+ ) {
247
+ bestOrder = camera.order;
248
+ bestId = camera.id;
249
+ }
250
+ }
251
+ return bestId;
252
+ }
253
+
51
254
  /**
52
- * Updates window properties.
255
+ * Convenience helper: presents this world in a window via target/layer bind.
53
256
  */
54
- export function updateWindow(worldId: number, props: WindowProps): void {
55
- emitIntent(worldId, {
56
- type: 'update-window',
57
- props,
257
+ export function presentWorldInWindow(
258
+ worldId: number,
259
+ args: {
260
+ windowId: number;
261
+ targetId?: number;
262
+ layout?: TargetLayerLayout;
263
+ cameraId?: number;
264
+ environmentId?: number;
265
+ },
266
+ ): { targetId: number; upsertCommandId: number; bindCommandId: number } {
267
+ requireInitialized();
268
+ const targetId = args.targetId ?? allocateGlobalId();
269
+ const upsertCommandId = upsertTarget(worldId, {
270
+ targetId,
271
+ kind: 'window',
272
+ windowId: args.windowId,
273
+ });
274
+ const bindCommandId = bindWorldToTarget(worldId, {
275
+ targetId,
276
+ layout: args.layout ?? {
277
+ left: { unit: 'percent', value: 0 },
278
+ top: { unit: 'percent', value: 0 },
279
+ width: { unit: 'percent', value: 100 },
280
+ height: { unit: 'percent', value: 100 },
281
+ zIndex: 0,
282
+ blendMode: 0,
283
+ },
284
+ cameraId: args.cameraId,
285
+ environmentId: args.environmentId,
58
286
  });
287
+ return { targetId, upsertCommandId, bindCommandId };
59
288
  }
60
289
 
61
290
  /**
62
- * Requests user attention for the window.
291
+ * Disposes a world and optionally releases its bound realm/targets.
292
+ * This operation is immediate on host state; core-side disposal commands are queued globally.
63
293
  */
64
- export function requestAttention(
294
+ export function disposeWorld(
65
295
  worldId: number,
66
- attentionType?: UserAttentionType,
296
+ opts: {
297
+ disposeRealm?: boolean;
298
+ disposeTargets?: boolean;
299
+ warnOnUndisposedResources?: boolean;
300
+ strictResourceLifecycle?: boolean;
301
+ } = {},
67
302
  ): void {
68
- emitIntent(worldId, {
69
- type: 'request-attention',
70
- attentionType,
303
+ requireInitialized();
304
+ const world = getWorldOrThrow(worldId);
305
+ const disposeRealm = opts.disposeRealm ?? true;
306
+ const disposeTargets = opts.disposeTargets ?? true;
307
+ const strictResourceLifecycle = opts.strictResourceLifecycle ?? false;
308
+ const warnOnUndisposedResources = opts.warnOnUndisposedResources ?? true;
309
+
310
+ let retainedCoreObjectCount = 0;
311
+ for (const store of world.components.values()) {
312
+ for (const comp of store.values()) {
313
+ if ('id' in comp && typeof comp.id === 'number') {
314
+ retainedCoreObjectCount++;
315
+ }
316
+ }
317
+ }
318
+ const hasRetainedTargets = world.targetLayerBindings.size > 0;
319
+ const hasPendingWork =
320
+ world.pendingIntents.length > 0 || world.pendingCommands.length > 0;
321
+
322
+ if (
323
+ (!disposeRealm && (retainedCoreObjectCount > 0 || hasPendingWork)) ||
324
+ (!disposeTargets && hasRetainedTargets)
325
+ ) {
326
+ const message =
327
+ `disposeWorld(${worldId}) called without fully releasing resources. ` +
328
+ `retainRealm=${!disposeRealm} retainTargets=${!disposeTargets} ` +
329
+ `trackedCoreObjects=${retainedCoreObjectCount} ` +
330
+ `targetBindings=${world.targetLayerBindings.size} ` +
331
+ `pendingIntents=${world.pendingIntents.length} ` +
332
+ `pendingCommands=${world.pendingCommands.length}`;
333
+ if (strictResourceLifecycle) {
334
+ throw new EngineError('WorldDisposeLifecycleRisk', message);
335
+ }
336
+ if (warnOnUndisposedResources) {
337
+ console.warn(`[World ${worldId}] ${message}`);
338
+ }
339
+ }
340
+
341
+ if (disposeTargets) {
342
+ for (const targetId of world.targetLayerBindings.keys()) {
343
+ let isShared = false;
344
+ for (const [otherWorldId, otherWorld] of engineState.worlds) {
345
+ if (otherWorldId === worldId) continue;
346
+ if (otherWorld.targetLayerBindings.has(targetId)) {
347
+ isShared = true;
348
+ break;
349
+ }
350
+ }
351
+ if (!isShared) {
352
+ enqueueGlobalCommand('cmd-target-dispose', { targetId });
353
+ }
354
+ }
355
+ }
356
+
357
+ if (disposeRealm && world.coreRealmId !== undefined) {
358
+ enqueueGlobalCommand('cmd-realm-dispose', { realmId: world.coreRealmId });
359
+ }
360
+
361
+ for (const [cmdId, trackedWorldId] of engineState.commandTracker) {
362
+ if (trackedWorldId === worldId) {
363
+ engineState.commandTracker.delete(cmdId);
364
+ }
365
+ }
366
+
367
+ engineState.worlds.delete(worldId);
368
+ markRoutingIndexDirty();
369
+ }
370
+
371
+ /**
372
+ * Configures global runtime diagnostics and pointer tracing.
373
+ */
374
+ export function setSystemDiagnostics(
375
+ args: CmdSystemDiagnosticsSetArgs,
376
+ ): number {
377
+ requireInitialized();
378
+ return enqueueGlobalCommand('cmd-system-diagnostics-set', args);
379
+ }
380
+
381
+ /**
382
+ * Requests the core to discard all pending upload buffers.
383
+ */
384
+ export function discardAllUploadBuffers(
385
+ args: CmdUploadBufferDiscardAllArgs = {},
386
+ ): number {
387
+ requireInitialized();
388
+ return enqueueGlobalCommand('cmd-upload-buffer-discard-all', args);
389
+ }
390
+
391
+ /**
392
+ * Sends an audio listener update command.
393
+ */
394
+ export function audioListenerUpdate(
395
+ worldId: number,
396
+ args: CmdAudioListenerUpdateArgs,
397
+ ): number {
398
+ return enqueueCommand(worldId, 'cmd-audio-listener-upsert', args);
399
+ }
400
+
401
+ /**
402
+ * Binds a texture id to a texture target output.
403
+ */
404
+ export function bindTextureToTarget(
405
+ worldId: number,
406
+ args: CmdTextureBindTargetArgs,
407
+ ): number {
408
+ return enqueueCommand(worldId, 'cmd-texture-bind-target', args);
409
+ }
410
+
411
+ /**
412
+ * Binds the audio listener to a model.
413
+ */
414
+ export function audioListenerCreate(
415
+ worldId: number,
416
+ args: CmdAudioListenerCreateArgs,
417
+ ): number {
418
+ return enqueueCommand(worldId, 'cmd-audio-listener-upsert', args);
419
+ }
420
+
421
+ /**
422
+ * Upserts audio listener params or binding.
423
+ */
424
+ export function audioListenerUpsert(
425
+ worldId: number,
426
+ args: CmdAudioListenerUpsertArgs,
427
+ ): number {
428
+ return enqueueCommand(worldId, 'cmd-audio-listener-upsert', args);
429
+ }
430
+
431
+ /**
432
+ * Disposes the audio listener binding.
433
+ */
434
+ export function audioListenerDispose(
435
+ worldId: number,
436
+ args: CmdAudioListenerDisposeArgs,
437
+ ): number {
438
+ return enqueueCommand(worldId, 'cmd-audio-listener-dispose', args);
439
+ }
440
+
441
+ /**
442
+ * Creates an audio resource from an uploaded buffer.
443
+ */
444
+ export function audioResourceCreate(
445
+ worldId: number,
446
+ args: CmdAudioResourceUpsertArgs,
447
+ ): number {
448
+ return enqueueCommand(worldId, 'cmd-audio-resource-upsert', args);
449
+ }
450
+
451
+ /**
452
+ * Pushes a chunk into a streaming audio resource.
453
+ */
454
+ export function audioResourcePush(
455
+ worldId: number,
456
+ args: CmdAudioResourceUpsertArgs,
457
+ ): number {
458
+ return enqueueCommand(worldId, 'cmd-audio-resource-upsert', args);
459
+ }
460
+
461
+ /**
462
+ * Disposes an audio resource.
463
+ */
464
+ export function audioResourceDispose(
465
+ worldId: number,
466
+ args: CmdAudioResourceDisposeArgs,
467
+ ): number {
468
+ return enqueueCommand(worldId, 'cmd-audio-resource-dispose', args);
469
+ }
470
+
471
+ /**
472
+ * Creates an audio source bound to a model.
473
+ */
474
+ export function audioSourceCreate(
475
+ worldId: number,
476
+ args: CmdAudioSourceCreateArgs,
477
+ ): number {
478
+ return enqueueCommand(worldId, 'cmd-audio-source-upsert', args);
479
+ }
480
+
481
+ /**
482
+ * Updates an audio source.
483
+ */
484
+ export function audioSourceUpdate(
485
+ worldId: number,
486
+ args: CmdAudioSourceUpdateArgs,
487
+ ): number {
488
+ return enqueueCommand(worldId, 'cmd-audio-source-upsert', args);
489
+ }
490
+
491
+ /**
492
+ * Upserts audio source params or binding.
493
+ */
494
+ export function audioSourceUpsert(
495
+ worldId: number,
496
+ args: CmdAudioSourceUpsertArgs,
497
+ ): number {
498
+ return enqueueCommand(worldId, 'cmd-audio-source-upsert', args);
499
+ }
500
+
501
+ /**
502
+ * Starts playback for an audio source.
503
+ */
504
+ export function audioSourcePlay(
505
+ worldId: number,
506
+ args: Omit<CmdAudioSourceTransportArgs, 'action'>,
507
+ ): number {
508
+ return enqueueCommand(worldId, 'cmd-audio-source-transport', {
509
+ ...args,
510
+ action: 'play',
71
511
  });
72
512
  }
73
513
 
74
514
  /**
75
- * Focuses the window.
515
+ * Pauses playback for an audio source.
76
516
  */
77
- export function focusWindow(worldId: number): void {
78
- emitIntent(worldId, {
79
- type: 'focus-window',
517
+ export function audioSourcePause(
518
+ worldId: number,
519
+ args: Omit<CmdAudioSourceTransportArgs, 'action'>,
520
+ ): number {
521
+ return enqueueCommand(worldId, 'cmd-audio-source-transport', {
522
+ ...args,
523
+ action: 'pause',
80
524
  });
81
525
  }
82
526
 
527
+ /**
528
+ * Stops playback for an audio source.
529
+ */
530
+ export function audioSourceStop(
531
+ worldId: number,
532
+ args: Omit<CmdAudioSourceTransportArgs, 'action'>,
533
+ ): number {
534
+ return enqueueCommand(worldId, 'cmd-audio-source-transport', {
535
+ ...args,
536
+ action: 'stop',
537
+ });
538
+ }
539
+
540
+ /**
541
+ * Requests a snapshot of audio runtime state.
542
+ */
543
+ export function audioStateGet(
544
+ worldId: number,
545
+ args: CmdAudioStateGetArgs = {},
546
+ ): number {
547
+ return enqueueCommand(worldId, 'cmd-audio-state-get', args);
548
+ }
549
+
550
+ /**
551
+ * Disposes an audio source.
552
+ */
553
+ export function audioSourceDispose(
554
+ worldId: number,
555
+ args: CmdAudioSourceDisposeArgs,
556
+ ): number {
557
+ return enqueueCommand(worldId, 'cmd-audio-source-dispose', args);
558
+ }
559
+
560
+ /**
561
+ * Updates a model pose (skinning) using an uploaded matrices buffer.
562
+ */
563
+ export function poseUpdate(worldId: number, args: CmdPoseUpdateArgs): number {
564
+ return enqueueCommand(worldId, 'cmd-pose-update', args);
565
+ }
566
+
83
567
  /**
84
568
  * Requests a list of resources from the engine for debugging.
85
569
  */
@@ -99,6 +583,65 @@ export function requestResourceList(
99
583
  });
100
584
  }
101
585
 
586
+ function resolveWorldWindowId(worldId: number): number {
587
+ const world = getWorldOrThrow(worldId);
588
+ if (world.primaryWindowId === undefined) {
589
+ for (const windowId of world.targetWindowBindings.values()) {
590
+ return windowId;
591
+ }
592
+ if (world.realmCreateArgs.hostWindowId !== undefined) {
593
+ return world.realmCreateArgs.hostWindowId;
594
+ }
595
+ throw new EngineError(
596
+ 'WindowNotFound',
597
+ `World ${worldId} has no window binding available for this command.`,
598
+ );
599
+ }
600
+ return world.primaryWindowId;
601
+ }
602
+
603
+ /** Requests model list for a world window context. */
604
+ export function listModels(worldId: number): number {
605
+ return enqueueCommand(worldId, 'cmd-model-list', {
606
+ windowId: resolveWorldWindowId(worldId),
607
+ });
608
+ }
609
+
610
+ /** Requests material list for a world window context. */
611
+ export function listMaterials(worldId: number): number {
612
+ return enqueueCommand(worldId, 'cmd-material-list', {
613
+ windowId: resolveWorldWindowId(worldId),
614
+ });
615
+ }
616
+
617
+ /** Requests texture list for a world window context. */
618
+ export function listTextures(worldId: number): number {
619
+ return enqueueCommand(worldId, 'cmd-texture-list', {
620
+ windowId: resolveWorldWindowId(worldId),
621
+ });
622
+ }
623
+
624
+ /** Requests geometry list for a world window context. */
625
+ export function listGeometries(worldId: number): number {
626
+ return enqueueCommand(worldId, 'cmd-geometry-list', {
627
+ windowId: resolveWorldWindowId(worldId),
628
+ });
629
+ }
630
+
631
+ /** Requests light list for a world window context. */
632
+ export function listLights(worldId: number): number {
633
+ return enqueueCommand(worldId, 'cmd-light-list', {
634
+ windowId: resolveWorldWindowId(worldId),
635
+ });
636
+ }
637
+
638
+ /** Requests camera list for a world window context. */
639
+ export function listCameras(worldId: number): number {
640
+ return enqueueCommand(worldId, 'cmd-camera-list', {
641
+ windowId: resolveWorldWindowId(worldId),
642
+ });
643
+ }
644
+
102
645
  /**
103
646
  * Disposes a material.
104
647
  */
@@ -162,6 +705,237 @@ export function configureEnvironment(
162
705
  });
163
706
  }
164
707
 
708
+ /**
709
+ * Disposes an environment profile.
710
+ * If omitted, defaults to this world id (engine convention).
711
+ */
712
+ export function disposeEnvironment(
713
+ worldId: number,
714
+ args: CmdEnvironmentDisposeArgs = { environmentId: worldId },
715
+ ): number {
716
+ return enqueueCommand(worldId, 'cmd-environment-dispose', args);
717
+ }
718
+
719
+ /**
720
+ * Defines or updates a UI theme.
721
+ */
722
+ export function uiDefineTheme(worldId: number, args: CmdUiThemeDefineArgs): void {
723
+ emitIntent(worldId, { type: 'ui-theme-define', args });
724
+ }
725
+
726
+ /**
727
+ * Disposes a UI theme.
728
+ */
729
+ export function uiDisposeTheme(worldId: number, args: CmdUiThemeDisposeArgs): void {
730
+ emitIntent(worldId, { type: 'ui-theme-dispose', args });
731
+ }
732
+
733
+ /**
734
+ * Creates a UI document.
735
+ */
736
+ export function uiCreateDocument(worldId: number, args: CmdUiDocumentCreateArgs): void {
737
+ emitIntent(worldId, { type: 'ui-document-create', args });
738
+ }
739
+
740
+ /**
741
+ * Disposes a UI document.
742
+ */
743
+ export function uiDisposeDocument(worldId: number, args: CmdUiDocumentDisposeArgs): void {
744
+ emitIntent(worldId, { type: 'ui-document-dispose', args });
745
+ }
746
+
747
+ /**
748
+ * Updates document rectangle.
749
+ */
750
+ export function uiSetDocumentRect(worldId: number, args: CmdUiDocumentSetRectArgs): void {
751
+ emitIntent(worldId, { type: 'ui-document-set-rect', args });
752
+ }
753
+
754
+ /**
755
+ * Updates document theme.
756
+ */
757
+ export function uiSetDocumentTheme(worldId: number, args: CmdUiDocumentSetThemeArgs): void {
758
+ emitIntent(worldId, { type: 'ui-document-set-theme', args });
759
+ }
760
+
761
+ /**
762
+ * Applies document ops.
763
+ */
764
+ export function uiApplyOps(worldId: number, args: CmdUiApplyOpsArgs): void {
765
+ emitIntent(worldId, { type: 'ui-apply-ops', args });
766
+ }
767
+
768
+ /**
769
+ * Requests UI document tree for introspection.
770
+ */
771
+ export function uiGetDocumentTree(worldId: number, args: CmdUiDocumentGetTreeArgs): void {
772
+ emitIntent(worldId, { type: 'ui-document-get-tree', args });
773
+ }
774
+
775
+ /**
776
+ * Requests UI layout rects for introspection.
777
+ */
778
+ export function uiGetLayoutRects(
779
+ worldId: number,
780
+ args: CmdUiDocumentGetLayoutRectsArgs,
781
+ ): void {
782
+ emitIntent(worldId, { type: 'ui-document-get-layout-rects', args });
783
+ }
784
+
785
+ /**
786
+ * Enables/disables runtime UI debug overlays.
787
+ */
788
+ export function uiSetDebug(worldId: number, args: CmdUiDebugSetArgs): void {
789
+ emitIntent(worldId, { type: 'ui-debug-set', args });
790
+ }
791
+
792
+ /**
793
+ * Sets focused UI node.
794
+ */
795
+ export function uiSetFocus(worldId: number, args: CmdUiFocusSetArgs): void {
796
+ emitIntent(worldId, { type: 'ui-focus-set', args });
797
+ }
798
+
799
+ /**
800
+ * Requests current UI focus state.
801
+ */
802
+ export function uiGetFocus(worldId: number, args: CmdUiFocusGetArgs = {}): void {
803
+ emitIntent(worldId, { type: 'ui-focus-get', args });
804
+ }
805
+
806
+ /**
807
+ * Configures UI event trace level/sampling.
808
+ */
809
+ export function uiSetEventTrace(worldId: number, args: CmdUiEventTraceSetArgs): void {
810
+ emitIntent(worldId, { type: 'ui-event-trace-set', args });
811
+ }
812
+
813
+ /**
814
+ * Creates a UI image from uploaded bytes.
815
+ */
816
+ export function uiCreateImageFromBuffer(
817
+ worldId: number,
818
+ args: CmdUiImageCreateFromBufferArgs,
819
+ ): void {
820
+ emitIntent(worldId, { type: 'ui-image-create-from-buffer', args });
821
+ }
822
+
823
+ /**
824
+ * Disposes a UI image.
825
+ */
826
+ export function uiDisposeImage(worldId: number, args: CmdUiImageDisposeArgs): void {
827
+ emitIntent(worldId, { type: 'ui-image-dispose', args });
828
+ }
829
+
830
+ /**
831
+ * Delivers host clipboard paste event to UI.
832
+ */
833
+ export function uiClipboardPaste(worldId: number, args: CmdUiClipboardPasteArgs): void {
834
+ emitIntent(worldId, { type: 'ui-clipboard-paste', args });
835
+ }
836
+
837
+ /**
838
+ * Delivers screenshot response bytes to UI.
839
+ */
840
+ export function uiScreenshotReply(worldId: number, args: CmdUiScreenshotReplyArgs): void {
841
+ emitIntent(worldId, { type: 'ui-screenshot-reply', args });
842
+ }
843
+
844
+ /**
845
+ * Delivers AccessKit action request to UI.
846
+ */
847
+ export function uiAccessKitActionRequest(
848
+ worldId: number,
849
+ args: CmdUiAccessKitActionRequestArgs,
850
+ ): void {
851
+ emitIntent(worldId, { type: 'ui-access-kit-action-request', args });
852
+ }
853
+
854
+ /**
855
+ * Registers or updates a UI form scope used for tab navigation.
856
+ */
857
+ export function uiFormUpsert(
858
+ worldId: number,
859
+ form: {
860
+ formId: string;
861
+ windowId: number;
862
+ realmId: number;
863
+ documentId: number;
864
+ disabled?: boolean;
865
+ cycleMode?: UiFocusCycleMode;
866
+ activeFieldsetId?: string;
867
+ },
868
+ ): void {
869
+ emitIntent(worldId, { type: 'ui-form-upsert', form });
870
+ }
871
+
872
+ /**
873
+ * Disposes a registered UI form scope.
874
+ */
875
+ export function uiFormDispose(worldId: number, formId: string): void {
876
+ emitIntent(worldId, { type: 'ui-form-dispose', formId });
877
+ }
878
+
879
+ /**
880
+ * Registers or updates fieldset metadata.
881
+ */
882
+ export function uiFieldsetUpsert(
883
+ worldId: number,
884
+ fieldset: {
885
+ formId: string;
886
+ fieldsetId: string;
887
+ disabled?: boolean;
888
+ legendNodeId?: number;
889
+ },
890
+ ): void {
891
+ emitIntent(worldId, { type: 'ui-fieldset-upsert', fieldset });
892
+ }
893
+
894
+ /**
895
+ * Disposes fieldset metadata.
896
+ */
897
+ export function uiFieldsetDispose(
898
+ worldId: number,
899
+ formId: string,
900
+ fieldsetId: string,
901
+ ): void {
902
+ emitIntent(worldId, { type: 'ui-fieldset-dispose', formId, fieldsetId });
903
+ }
904
+
905
+ /**
906
+ * Registers or updates focusable node metadata.
907
+ */
908
+ export function uiFocusableUpsert(
909
+ worldId: number,
910
+ focusable: {
911
+ formId: string;
912
+ nodeId: number;
913
+ tabIndex?: number;
914
+ fieldsetId?: string;
915
+ disabled?: boolean;
916
+ orderHint?: number;
917
+ },
918
+ ): void {
919
+ emitIntent(worldId, { type: 'ui-focusable-upsert', focusable });
920
+ }
921
+
922
+ /**
923
+ * Disposes focusable node metadata.
924
+ */
925
+ export function uiFocusableDispose(worldId: number, nodeId: number): void {
926
+ emitIntent(worldId, { type: 'ui-focusable-dispose', nodeId });
927
+ }
928
+
929
+ /**
930
+ * Advances focus within a form scope using tab ordering.
931
+ */
932
+ export function uiFocusNext(
933
+ worldId: number,
934
+ args: { windowId: number; backwards?: boolean; formId?: string },
935
+ ): void {
936
+ emitIntent(worldId, { type: 'ui-focus-next', ...args });
937
+ }
938
+
165
939
  /**
166
940
  * Draws a debug gizmo line for one frame.
167
941
  */
@@ -310,6 +1084,12 @@ export function setParent(
310
1084
  entityId: number,
311
1085
  parentId: number | null,
312
1086
  ): void {
1087
+ if (parentId !== null && parentId === entityId) {
1088
+ throw new EngineError(
1089
+ 'InvalidParent',
1090
+ `Entity ${entityId} cannot be parent of itself.`,
1091
+ );
1092
+ }
313
1093
  emitIntent(worldId, {
314
1094
  type: 'set-parent',
315
1095
  entityId,
@@ -326,8 +1106,8 @@ export function setParent(
326
1106
  */
327
1107
  export function createMaterial(worldId: number, props: MaterialProps): number {
328
1108
  requireInitialized();
329
- const world = getWorldOrThrow(worldId);
330
- const resourceId = world.nextCoreId++;
1109
+ getWorldOrThrow(worldId);
1110
+ const resourceId = allocateGlobalId();
331
1111
 
332
1112
  emitIntent(worldId, {
333
1113
  type: 'create-material',
@@ -343,8 +1123,8 @@ export function createMaterial(worldId: number, props: MaterialProps): number {
343
1123
  */
344
1124
  export function createGeometry(worldId: number, props: GeometryProps): number {
345
1125
  requireInitialized();
346
- const world = getWorldOrThrow(worldId);
347
- const resourceId = world.nextCoreId++;
1126
+ getWorldOrThrow(worldId);
1127
+ const resourceId = allocateGlobalId();
348
1128
 
349
1129
  emitIntent(worldId, {
350
1130
  type: 'create-geometry',
@@ -360,8 +1140,8 @@ export function createGeometry(worldId: number, props: GeometryProps): number {
360
1140
  */
361
1141
  export function createTexture(worldId: number, props: TextureProps): number {
362
1142
  requireInitialized();
363
- const world = getWorldOrThrow(worldId);
364
- const resourceId = world.nextCoreId++;
1143
+ getWorldOrThrow(worldId);
1144
+ const resourceId = allocateGlobalId();
365
1145
 
366
1146
  emitIntent(worldId, {
367
1147
  type: 'create-texture',
@@ -514,3 +1294,121 @@ export function getWindowScaleFactor(worldId: number): number {
514
1294
  const state = getWindowState(worldId);
515
1295
  return state?.scaleFactor ?? 1.0;
516
1296
  }
1297
+
1298
+ function getGamepadState(worldId: number):
1299
+ | {
1300
+ connected: Map<number, { name: string }>;
1301
+ buttons: Map<number, Map<number, { pressed: boolean; value: number }>>;
1302
+ axes: Map<number, Map<number, number>>;
1303
+ eventsThisFrame: GamepadEvent[];
1304
+ }
1305
+ | undefined {
1306
+ requireInitialized();
1307
+ const world = getWorldOrThrow(worldId);
1308
+ const worldStore = world.components.get(WORLD_ENTITY_ID);
1309
+ return worldStore?.get('GamepadState') as
1310
+ | {
1311
+ connected: Map<number, { name: string }>;
1312
+ buttons: Map<number, Map<number, { pressed: boolean; value: number }>>;
1313
+ axes: Map<number, Map<number, number>>;
1314
+ eventsThisFrame: GamepadEvent[];
1315
+ }
1316
+ | undefined;
1317
+ }
1318
+
1319
+ function getSystemEventState(worldId: number):
1320
+ | {
1321
+ eventsThisFrame: SystemEvent[];
1322
+ lastError?: {
1323
+ scope: string;
1324
+ message: string;
1325
+ commandId?: number;
1326
+ commandType?: string;
1327
+ };
1328
+ }
1329
+ | undefined {
1330
+ requireInitialized();
1331
+ const world = getWorldOrThrow(worldId);
1332
+ const worldStore = world.components.get(WORLD_ENTITY_ID);
1333
+ return worldStore?.get('SystemEventState') as
1334
+ | {
1335
+ eventsThisFrame: SystemEvent[];
1336
+ lastError?: {
1337
+ scope: string;
1338
+ message: string;
1339
+ commandId?: number;
1340
+ commandType?: string;
1341
+ };
1342
+ }
1343
+ | undefined;
1344
+ }
1345
+
1346
+ function getUiEventState(worldId: number):
1347
+ | { eventsThisFrame: UiEvent[] }
1348
+ | undefined {
1349
+ requireInitialized();
1350
+ const world = getWorldOrThrow(worldId);
1351
+ const worldStore = world.components.get(WORLD_ENTITY_ID);
1352
+ return worldStore?.get('UiEventState') as
1353
+ | { eventsThisFrame: UiEvent[] }
1354
+ | undefined;
1355
+ }
1356
+
1357
+ export function getGamepadEvents(worldId: number): GamepadEvent[] {
1358
+ return getGamepadState(worldId)?.eventsThisFrame ?? [];
1359
+ }
1360
+
1361
+ /** Lists currently connected gamepads sorted by id. */
1362
+ export function getConnectedGamepads(
1363
+ worldId: number,
1364
+ ): Array<{ gamepadId: number; name: string }> {
1365
+ const connected = getGamepadState(worldId)?.connected;
1366
+ if (!connected) return [];
1367
+ const out: Array<{ gamepadId: number; name: string }> = [];
1368
+ for (const [gamepadId, info] of connected) {
1369
+ out.push({ gamepadId, name: info.name });
1370
+ }
1371
+ out.sort((a, b) => a.gamepadId - b.gamepadId);
1372
+ return out;
1373
+ }
1374
+
1375
+ /** Returns current value of a gamepad axis or 0 when unavailable. */
1376
+ export function getGamepadAxis(
1377
+ worldId: number,
1378
+ gamepadId: number,
1379
+ axis: number,
1380
+ ): number {
1381
+ return getGamepadState(worldId)?.axes.get(gamepadId)?.get(axis) ?? 0;
1382
+ }
1383
+
1384
+ /** Returns whether a gamepad button is currently pressed. */
1385
+ export function isGamepadButtonPressed(
1386
+ worldId: number,
1387
+ gamepadId: number,
1388
+ button: number,
1389
+ ): boolean {
1390
+ return (
1391
+ getGamepadState(worldId)?.buttons.get(gamepadId)?.get(button)?.pressed ??
1392
+ false
1393
+ );
1394
+ }
1395
+
1396
+ /** Returns system events mirrored in the current frame. */
1397
+ export function getSystemEvents(worldId: number): SystemEvent[] {
1398
+ return getSystemEventState(worldId)?.eventsThisFrame ?? [];
1399
+ }
1400
+
1401
+ /** Returns the last system error seen by the world, if any. */
1402
+ export function getLastSystemError(worldId: number): {
1403
+ scope: string;
1404
+ message: string;
1405
+ commandId?: number;
1406
+ commandType?: string;
1407
+ } | null {
1408
+ return getSystemEventState(worldId)?.lastError ?? null;
1409
+ }
1410
+
1411
+ /** Returns UI events mirrored in the current frame. */
1412
+ export function getUiEvents(worldId: number): UiEvent[] {
1413
+ return getUiEventState(worldId)?.eventsThisFrame ?? [];
1414
+ }