@vulfram/engine 0.14.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 (54) 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 +69 -52
  9. package/src/engine/ecs/index.ts +185 -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 -268
  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 +37 -102
  23. package/src/engine/window/manager.ts +168 -0
  24. package/src/engine/world/entities.ts +821 -78
  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 +73 -48
  32. package/src/types/cmds/camera.ts +12 -8
  33. package/src/types/cmds/environment.ts +9 -3
  34. package/src/types/cmds/geometry.ts +13 -14
  35. package/src/types/cmds/index.ts +198 -168
  36. package/src/types/cmds/light.ts +12 -11
  37. package/src/types/cmds/material.ts +9 -11
  38. package/src/types/cmds/model.ts +17 -15
  39. package/src/types/cmds/realm.ts +25 -0
  40. package/src/types/cmds/system.ts +19 -0
  41. package/src/types/cmds/target.ts +82 -0
  42. package/src/types/cmds/texture.ts +13 -3
  43. package/src/types/cmds/ui.ts +220 -0
  44. package/src/types/cmds/window.ts +41 -204
  45. package/src/types/events/index.ts +4 -1
  46. package/src/types/events/pointer.ts +42 -13
  47. package/src/types/events/system.ts +144 -30
  48. package/src/types/events/ui.ts +21 -0
  49. package/src/types/index.ts +1 -0
  50. package/src/types/json.ts +15 -0
  51. package/src/window.ts +8 -0
  52. package/src/world-ui.ts +2 -0
  53. package/src/world3d.ts +10 -0
  54. package/tsconfig.json +0 -29
@@ -1,26 +1,66 @@
1
1
  import type { ShadowConfig } from '../../types/cmds/shadow';
2
2
  import type { EnvironmentConfig } from '../../types/cmds/environment';
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';
3
13
  import type {
4
14
  CmdAudioListenerCreateArgs,
5
15
  CmdAudioListenerDisposeArgs,
16
+ CmdAudioListenerUpsertArgs,
6
17
  CmdAudioListenerUpdateArgs,
7
- CmdAudioResourceCreateArgs,
18
+ CmdAudioResourceUpsertArgs,
8
19
  CmdAudioResourceDisposeArgs,
9
- CmdAudioResourcePushArgs,
20
+ CmdAudioSourceTransportArgs,
10
21
  CmdAudioSourceCreateArgs,
11
22
  CmdAudioSourceDisposeArgs,
12
- CmdAudioSourcePauseArgs,
13
- CmdAudioSourcePlayArgs,
14
- CmdAudioSourceStopArgs,
23
+ CmdAudioSourceUpsertArgs,
15
24
  CmdAudioSourceUpdateArgs,
25
+ CmdAudioStateGetArgs,
16
26
  } from '../../types/cmds/audio';
17
27
  import type { CmdPoseUpdateArgs } from '../../types/cmds/model';
18
- import type { NotificationLevel, UserAttentionType } from '../../types/kinds';
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';
19
55
  import { getWorldOrThrow, requireInitialized } from '../bridge/guards';
20
- import { enqueueCommand } from '../bridge/dispatch';
56
+ import {
57
+ enqueueCommand,
58
+ enqueueGlobalCommand,
59
+ markRoutingIndexDirty,
60
+ } from '../bridge/dispatch';
21
61
  import type {
62
+ CameraComponent,
22
63
  CameraProps,
23
- CreateWindowProps,
24
64
  GeometryProps,
25
65
  InputStateComponent,
26
66
  Intent,
@@ -30,10 +70,35 @@ import type {
30
70
  TagProps,
31
71
  TextureProps,
32
72
  TransformProps,
33
- WindowProps,
73
+ UiFocusCycleMode,
34
74
  WindowStateComponent,
35
75
  } from '../ecs';
36
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
+ }
37
102
 
38
103
  /**
39
104
  * Emits an intent to the specified world.
@@ -57,6 +122,272 @@ export function getModelId(worldId: number, entityId: number): number | null {
57
122
  return model?.id ?? null;
58
123
  }
59
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,
206
+ });
207
+ }
208
+
209
+ /**
210
+ * Unbinds this world's realm from a target layer.
211
+ */
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,
232
+ });
233
+ }
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
+
254
+ /**
255
+ * Convenience helper: presents this world in a window via target/layer bind.
256
+ */
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,
286
+ });
287
+ return { targetId, upsertCommandId, bindCommandId };
288
+ }
289
+
290
+ /**
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.
293
+ */
294
+ export function disposeWorld(
295
+ worldId: number,
296
+ opts: {
297
+ disposeRealm?: boolean;
298
+ disposeTargets?: boolean;
299
+ warnOnUndisposedResources?: boolean;
300
+ strictResourceLifecycle?: boolean;
301
+ } = {},
302
+ ): void {
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
+
60
391
  /**
61
392
  * Sends an audio listener update command.
62
393
  */
@@ -64,7 +395,17 @@ export function audioListenerUpdate(
64
395
  worldId: number,
65
396
  args: CmdAudioListenerUpdateArgs,
66
397
  ): number {
67
- return enqueueCommand(worldId, 'cmd-audio-listener-update', args);
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);
68
409
  }
69
410
 
70
411
  /**
@@ -74,7 +415,17 @@ export function audioListenerCreate(
74
415
  worldId: number,
75
416
  args: CmdAudioListenerCreateArgs,
76
417
  ): number {
77
- return enqueueCommand(worldId, 'cmd-audio-listener-create', args);
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);
78
429
  }
79
430
 
80
431
  /**
@@ -92,9 +443,9 @@ export function audioListenerDispose(
92
443
  */
93
444
  export function audioResourceCreate(
94
445
  worldId: number,
95
- args: CmdAudioResourceCreateArgs,
446
+ args: CmdAudioResourceUpsertArgs,
96
447
  ): number {
97
- return enqueueCommand(worldId, 'cmd-audio-resource-create', args);
448
+ return enqueueCommand(worldId, 'cmd-audio-resource-upsert', args);
98
449
  }
99
450
 
100
451
  /**
@@ -102,9 +453,9 @@ export function audioResourceCreate(
102
453
  */
103
454
  export function audioResourcePush(
104
455
  worldId: number,
105
- args: CmdAudioResourcePushArgs,
456
+ args: CmdAudioResourceUpsertArgs,
106
457
  ): number {
107
- return enqueueCommand(worldId, 'cmd-audio-resource-push', args);
458
+ return enqueueCommand(worldId, 'cmd-audio-resource-upsert', args);
108
459
  }
109
460
 
110
461
  /**
@@ -124,7 +475,7 @@ export function audioSourceCreate(
124
475
  worldId: number,
125
476
  args: CmdAudioSourceCreateArgs,
126
477
  ): number {
127
- return enqueueCommand(worldId, 'cmd-audio-source-create', args);
478
+ return enqueueCommand(worldId, 'cmd-audio-source-upsert', args);
128
479
  }
129
480
 
130
481
  /**
@@ -134,7 +485,17 @@ export function audioSourceUpdate(
134
485
  worldId: number,
135
486
  args: CmdAudioSourceUpdateArgs,
136
487
  ): number {
137
- return enqueueCommand(worldId, 'cmd-audio-source-update', args);
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);
138
499
  }
139
500
 
140
501
  /**
@@ -142,9 +503,12 @@ export function audioSourceUpdate(
142
503
  */
143
504
  export function audioSourcePlay(
144
505
  worldId: number,
145
- args: CmdAudioSourcePlayArgs,
506
+ args: Omit<CmdAudioSourceTransportArgs, 'action'>,
146
507
  ): number {
147
- return enqueueCommand(worldId, 'cmd-audio-source-play', args);
508
+ return enqueueCommand(worldId, 'cmd-audio-source-transport', {
509
+ ...args,
510
+ action: 'play',
511
+ });
148
512
  }
149
513
 
150
514
  /**
@@ -152,9 +516,12 @@ export function audioSourcePlay(
152
516
  */
153
517
  export function audioSourcePause(
154
518
  worldId: number,
155
- args: CmdAudioSourcePauseArgs,
519
+ args: Omit<CmdAudioSourceTransportArgs, 'action'>,
156
520
  ): number {
157
- return enqueueCommand(worldId, 'cmd-audio-source-pause', args);
521
+ return enqueueCommand(worldId, 'cmd-audio-source-transport', {
522
+ ...args,
523
+ action: 'pause',
524
+ });
158
525
  }
159
526
 
160
527
  /**
@@ -162,9 +529,22 @@ export function audioSourcePause(
162
529
  */
163
530
  export function audioSourceStop(
164
531
  worldId: number,
165
- args: CmdAudioSourceStopArgs,
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 = {},
166
546
  ): number {
167
- return enqueueCommand(worldId, 'cmd-audio-source-stop', args);
547
+ return enqueueCommand(worldId, 'cmd-audio-state-get', args);
168
548
  }
169
549
 
170
550
  /**
@@ -185,72 +565,80 @@ export function poseUpdate(worldId: number, args: CmdPoseUpdateArgs): number {
185
565
  }
186
566
 
187
567
  /**
188
- * Emits an intent to create a window in the core.
568
+ * Requests a list of resources from the engine for debugging.
189
569
  */
190
- export function createWindow(worldId: number, props: CreateWindowProps): void {
570
+ export function requestResourceList(
571
+ worldId: number,
572
+ resourceType:
573
+ | 'model'
574
+ | 'material'
575
+ | 'texture'
576
+ | 'geometry'
577
+ | 'light'
578
+ | 'camera',
579
+ ): void {
191
580
  emitIntent(worldId, {
192
- type: 'create-window',
193
- props,
581
+ type: 'request-resource-list',
582
+ resourceType,
194
583
  });
195
584
  }
196
585
 
197
- /**
198
- * Emits an intent to close the window associated with this world.
199
- */
200
- export function closeWindow(worldId: number): void {
201
- emitIntent(worldId, {
202
- type: 'close-window',
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),
203
607
  });
204
608
  }
205
609
 
206
- /**
207
- * Updates window properties.
208
- */
209
- export function updateWindow(worldId: number, props: WindowProps): void {
210
- emitIntent(worldId, {
211
- type: 'update-window',
212
- props,
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),
213
614
  });
214
615
  }
215
616
 
216
- /**
217
- * Requests user attention for the window.
218
- */
219
- export function requestAttention(
220
- worldId: number,
221
- attentionType?: UserAttentionType,
222
- ): void {
223
- emitIntent(worldId, {
224
- type: 'request-attention',
225
- attentionType,
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),
226
621
  });
227
622
  }
228
623
 
229
- /**
230
- * Focuses the window.
231
- */
232
- export function focusWindow(worldId: number): void {
233
- emitIntent(worldId, {
234
- type: 'focus-window',
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),
235
628
  });
236
629
  }
237
630
 
238
- /**
239
- * Requests a list of resources from the engine for debugging.
240
- */
241
- export function requestResourceList(
242
- worldId: number,
243
- resourceType:
244
- | 'model'
245
- | 'material'
246
- | 'texture'
247
- | 'geometry'
248
- | 'light'
249
- | 'camera',
250
- ): void {
251
- emitIntent(worldId, {
252
- type: 'request-resource-list',
253
- resourceType,
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),
254
642
  });
255
643
  }
256
644
 
@@ -317,6 +705,237 @@ export function configureEnvironment(
317
705
  });
318
706
  }
319
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
+
320
939
  /**
321
940
  * Draws a debug gizmo line for one frame.
322
941
  */
@@ -465,6 +1084,12 @@ export function setParent(
465
1084
  entityId: number,
466
1085
  parentId: number | null,
467
1086
  ): void {
1087
+ if (parentId !== null && parentId === entityId) {
1088
+ throw new EngineError(
1089
+ 'InvalidParent',
1090
+ `Entity ${entityId} cannot be parent of itself.`,
1091
+ );
1092
+ }
468
1093
  emitIntent(worldId, {
469
1094
  type: 'set-parent',
470
1095
  entityId,
@@ -481,8 +1106,8 @@ export function setParent(
481
1106
  */
482
1107
  export function createMaterial(worldId: number, props: MaterialProps): number {
483
1108
  requireInitialized();
484
- const world = getWorldOrThrow(worldId);
485
- const resourceId = world.nextCoreId++;
1109
+ getWorldOrThrow(worldId);
1110
+ const resourceId = allocateGlobalId();
486
1111
 
487
1112
  emitIntent(worldId, {
488
1113
  type: 'create-material',
@@ -498,8 +1123,8 @@ export function createMaterial(worldId: number, props: MaterialProps): number {
498
1123
  */
499
1124
  export function createGeometry(worldId: number, props: GeometryProps): number {
500
1125
  requireInitialized();
501
- const world = getWorldOrThrow(worldId);
502
- const resourceId = world.nextCoreId++;
1126
+ getWorldOrThrow(worldId);
1127
+ const resourceId = allocateGlobalId();
503
1128
 
504
1129
  emitIntent(worldId, {
505
1130
  type: 'create-geometry',
@@ -515,8 +1140,8 @@ export function createGeometry(worldId: number, props: GeometryProps): number {
515
1140
  */
516
1141
  export function createTexture(worldId: number, props: TextureProps): number {
517
1142
  requireInitialized();
518
- const world = getWorldOrThrow(worldId);
519
- const resourceId = world.nextCoreId++;
1143
+ getWorldOrThrow(worldId);
1144
+ const resourceId = allocateGlobalId();
520
1145
 
521
1146
  emitIntent(worldId, {
522
1147
  type: 'create-texture',
@@ -669,3 +1294,121 @@ export function getWindowScaleFactor(worldId: number): number {
669
1294
  const state = getWindowState(worldId);
670
1295
  return state?.scaleFactor ?? 1.0;
671
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
+ }