@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,5 +1,6 @@
1
1
  import type { CommandResponseEnvelope, EngineCmd } from '../../types/cmds';
2
2
  import type { EngineEvent } from '../../types/events';
3
+ import { EngineError } from '../errors';
3
4
  import { engineState } from '../state';
4
5
  import { getWorldOrThrow, requireInitialized } from './guards';
5
6
 
@@ -7,6 +8,60 @@ import { getWorldOrThrow, requireInitialized } from './guards';
7
8
  * Limits for Backpressure management.
8
9
  */
9
10
  const MAX_BATCH_COMMANDS = 2048;
11
+ const MAX_BATCH_ESTIMATED_BYTES = 60 * 1024;
12
+
13
+ function estimateSerializedBytes(value: unknown): number {
14
+ if (value === null || value === undefined) return 1;
15
+ const valueType = typeof value;
16
+ if (valueType === 'boolean') return 1;
17
+ if (valueType === 'number') return 8;
18
+ if (valueType === 'string') return 2 + (value as string).length * 2;
19
+ if (ArrayBuffer.isView(value)) return (value as ArrayBufferView).byteLength + 8;
20
+ if (value instanceof ArrayBuffer) return value.byteLength + 8;
21
+
22
+ if (Array.isArray(value)) {
23
+ let total = 8;
24
+ for (let i = 0; i < value.length; i++) {
25
+ total += estimateSerializedBytes(value[i]);
26
+ }
27
+ return total;
28
+ }
29
+
30
+ if (valueType === 'object') {
31
+ const record = value as Record<string, unknown>;
32
+ let total = 8;
33
+ for (const [key, nested] of Object.entries(record)) {
34
+ if (nested === undefined) continue;
35
+ total += 2 + key.length * 2;
36
+ total += estimateSerializedBytes(nested);
37
+ }
38
+ return total;
39
+ }
40
+
41
+ return 4;
42
+ }
43
+
44
+ function estimateEnvelopeBytes(envelope: { id: number; type: string; content: unknown }): number {
45
+ return 24 + envelope.type.length * 2 + estimateSerializedBytes(envelope.content);
46
+ }
47
+
48
+ function maybeCompactQueue<T>(queue: T[], head: number): number {
49
+ if (head <= 0) return head;
50
+ if (head === queue.length) {
51
+ queue.length = 0;
52
+ return 0;
53
+ }
54
+ if (head >= 1024 && head * 2 >= queue.length) {
55
+ queue.copyWithin(0, head);
56
+ queue.length -= head;
57
+ return 0;
58
+ }
59
+ return head;
60
+ }
61
+
62
+ export function markRoutingIndexDirty(): void {
63
+ engineState.routingIndex.dirty = true;
64
+ }
10
65
 
11
66
  /**
12
67
  * Enqueues a command to be sent to the core in the next tick.
@@ -21,13 +76,49 @@ export function enqueueCommand<T extends EngineCmd['type']>(
21
76
  const world = getWorldOrThrow(worldId);
22
77
  const id = engineState.nextCommandId++;
23
78
 
24
- // Contract: windowId must match worldId (windowId) if present in content
25
- const typedContent = content as Record<string, unknown>;
26
- if ('windowId' in typedContent) {
27
- typedContent.windowId = world.windowId;
79
+ // Contract: realmId resolves from world core realm; windowId may default to primary binding.
80
+ const typedContent = {
81
+ ...(content as Record<string, unknown>),
82
+ } as Record<string, unknown>;
83
+ if (
84
+ 'windowId' in typedContent &&
85
+ typedContent.windowId == null &&
86
+ world.primaryWindowId !== undefined
87
+ ) {
88
+ typedContent.windowId = world.primaryWindowId;
89
+ }
90
+ if ('realmId' in typedContent && typedContent.realmId == null) {
91
+ if (world.coreRealmId === undefined) {
92
+ throw new EngineError(
93
+ 'WorldNotReady',
94
+ `World ${worldId} realm is not ready yet. Wait for at least one tick after createWorld.`,
95
+ );
96
+ }
97
+ typedContent.realmId = world.coreRealmId;
28
98
  }
29
99
 
30
- world.pendingCommands.push({ id, type, content });
100
+ world.pendingCommands.push({
101
+ id,
102
+ type,
103
+ content: typedContent as unknown as Extract<
104
+ EngineCmd,
105
+ { type: T }
106
+ >['content'],
107
+ });
108
+ return id;
109
+ }
110
+
111
+ /**
112
+ * Enqueues a command not scoped to a specific world (window management, etc).
113
+ */
114
+ export function enqueueGlobalCommand<T extends EngineCmd['type']>(
115
+ type: T,
116
+ content: Extract<EngineCmd, { type: T }>['content'],
117
+ ): number {
118
+ requireInitialized();
119
+ const id = engineState.nextCommandId++;
120
+ engineState.globalCommandTracker.add(id);
121
+ engineState.globalPendingCommands.push({ id, type, content });
31
122
  return id;
32
123
  }
33
124
 
@@ -37,63 +128,192 @@ export function enqueueCommand<T extends EngineCmd['type']>(
37
128
  */
38
129
  export function collectCommands(): void {
39
130
  let collectedCount = 0;
131
+ let collectedBytes = 0;
132
+
133
+ const globalAvailable =
134
+ engineState.globalPendingCommands.length - engineState.globalPendingCommandsHead;
135
+ if (globalAvailable > 0) {
136
+ const start = engineState.globalPendingCommandsHead;
137
+ let end = start;
138
+ while (
139
+ end < engineState.globalPendingCommands.length &&
140
+ collectedCount < MAX_BATCH_COMMANDS &&
141
+ collectedBytes < MAX_BATCH_ESTIMATED_BYTES
142
+ ) {
143
+ const cmd = engineState.globalPendingCommands[end]!;
144
+ const estimateBytes = estimateEnvelopeBytes(cmd);
145
+ if (collectedBytes > 0 && collectedBytes + estimateBytes > MAX_BATCH_ESTIMATED_BYTES) {
146
+ break;
147
+ }
148
+ engineState.commandBatch.push(cmd);
149
+ collectedBytes += estimateBytes;
150
+ collectedCount++;
151
+ end++;
152
+ }
153
+ engineState.globalPendingCommandsHead = end;
154
+ engineState.globalPendingCommandsHead = maybeCompactQueue(
155
+ engineState.globalPendingCommands,
156
+ engineState.globalPendingCommandsHead,
157
+ );
158
+ }
40
159
 
41
160
  for (const [worldId, world] of engineState.worlds) {
42
- if (collectedCount >= MAX_BATCH_COMMANDS) {
161
+ if (
162
+ collectedCount >= MAX_BATCH_COMMANDS ||
163
+ collectedBytes >= MAX_BATCH_ESTIMATED_BYTES
164
+ ) {
43
165
  // Backpressure: If we reached the limit, stop collecting for this frame.
44
166
  // Remaining commands will wait for the next frame.
45
167
  break;
46
168
  }
47
169
 
48
- if (world.pendingCommands.length > 0) {
49
- // Determine how many we can take from this world
50
- const remainingSpace = MAX_BATCH_COMMANDS - collectedCount;
51
- const amountToTake = Math.min(
52
- world.pendingCommands.length,
53
- remainingSpace,
54
- );
55
-
56
- const cmdsToPush = world.pendingCommands.splice(0, amountToTake);
57
- for (const cmd of cmdsToPush) {
170
+ const worldAvailable = world.pendingCommands.length - world.pendingCommandsHead;
171
+ if (worldAvailable > 0) {
172
+ const start = world.pendingCommandsHead;
173
+ let end = start;
174
+ while (
175
+ end < world.pendingCommands.length &&
176
+ collectedCount < MAX_BATCH_COMMANDS &&
177
+ collectedBytes < MAX_BATCH_ESTIMATED_BYTES
178
+ ) {
179
+ const cmd = world.pendingCommands[end]!;
180
+ const estimateBytes = estimateEnvelopeBytes(cmd);
181
+ if (collectedBytes > 0 && collectedBytes + estimateBytes > MAX_BATCH_ESTIMATED_BYTES) {
182
+ break;
183
+ }
58
184
  engineState.commandBatch.push(cmd);
59
185
  engineState.commandTracker.set(cmd.id, worldId);
186
+ collectedBytes += estimateBytes;
187
+ collectedCount++;
188
+ end++;
60
189
  }
61
- collectedCount += amountToTake;
190
+ world.pendingCommandsHead = end;
191
+ world.pendingCommandsHead = maybeCompactQueue(
192
+ world.pendingCommands,
193
+ world.pendingCommandsHead,
194
+ );
195
+ }
196
+ }
197
+ }
198
+
199
+ function ensureRoutingIndex(): void {
200
+ if (!engineState.routingIndex.dirty) return;
201
+
202
+ const byWindowId = new Map<number, number[]>();
203
+ const byRealmId = new Map<number, number[]>();
204
+ const byTargetId = new Map<number, number[]>();
205
+
206
+ for (const [worldId, world] of engineState.worlds) {
207
+ for (const boundWindowId of world.boundWindowIds) {
208
+ const ids = byWindowId.get(boundWindowId);
209
+ if (ids) ids.push(worldId);
210
+ else byWindowId.set(boundWindowId, [worldId]);
211
+ }
212
+
213
+ if (typeof world.coreRealmId === 'number') {
214
+ const ids = byRealmId.get(world.coreRealmId);
215
+ if (ids) ids.push(worldId);
216
+ else byRealmId.set(world.coreRealmId, [worldId]);
217
+ }
218
+
219
+ for (const targetId of world.targetLayerBindings.keys()) {
220
+ const ids = byTargetId.get(targetId);
221
+ if (ids) ids.push(worldId);
222
+ else byTargetId.set(targetId, [worldId]);
62
223
  }
63
224
  }
225
+
226
+ engineState.routingIndex.byWindowId = byWindowId;
227
+ engineState.routingIndex.byRealmId = byRealmId;
228
+ engineState.routingIndex.byTargetId = byTargetId;
229
+ engineState.routingIndex.dirty = false;
230
+ }
231
+
232
+ function extractEventScopeIds(event: EngineEvent): {
233
+ windowId?: number;
234
+ realmId?: number;
235
+ targetId?: number;
236
+ } {
237
+ const content = event.content as
238
+ | { data?: Record<string, unknown> }
239
+ | Record<string, unknown>;
240
+ const scopedDataCandidate =
241
+ 'data' in content && content.data && typeof content.data === 'object'
242
+ ? content.data
243
+ : content;
244
+
245
+ if (!scopedDataCandidate || typeof scopedDataCandidate !== 'object') {
246
+ return {};
247
+ }
248
+ const scopedData = scopedDataCandidate as Record<string, unknown>;
249
+
250
+ const windowId =
251
+ typeof scopedData.windowId === 'number' ? scopedData.windowId : undefined;
252
+ const realmId =
253
+ typeof scopedData.realmId === 'number' ? scopedData.realmId : undefined;
254
+ const targetId =
255
+ typeof scopedData.targetId === 'number' ? scopedData.targetId : undefined;
256
+
257
+ return { windowId, realmId, targetId };
64
258
  }
65
259
 
66
260
  /**
67
261
  * Routes core events to their respective worlds based on windowId.
68
262
  */
69
263
  export function routeEvents(events: EngineEvent[]): void {
264
+ ensureRoutingIndex();
265
+ const worldsByWindowId = engineState.routingIndex.byWindowId;
266
+ const worldsByRealmId = engineState.routingIndex.byRealmId;
267
+ const worldsByTargetId = engineState.routingIndex.byTargetId;
268
+
70
269
  for (const event of events) {
71
- // Determine windowId from event content
72
- // Events have structure: { type, content: { event, data: { windowId, ... } } }
73
- let windowId: number | undefined;
74
-
75
- if ('data' in event.content && event.content.data) {
76
- const eventData = event.content.data as unknown as Record<
77
- string,
78
- unknown
79
- >;
80
- windowId =
81
- typeof eventData.windowId === 'number' ? eventData.windowId : undefined;
82
- } else if ('windowId' in event.content) {
83
- const eventContent = event.content as Record<string, unknown>;
84
- windowId =
85
- typeof eventContent.windowId === 'number'
86
- ? eventContent.windowId
87
- : undefined;
88
- }
270
+ const { windowId, realmId, targetId } = extractEventScopeIds(event);
89
271
 
90
272
  if (windowId !== undefined) {
91
- const world = engineState.worlds.get(windowId);
92
- if (world) {
93
- world.inboundEvents.push(event);
273
+ const worldIds = worldsByWindowId.get(windowId);
274
+ if (!worldIds) {
275
+ continue;
94
276
  }
95
- } else {
96
- // Global events (if any) could be handled here
277
+ for (let i = 0; i < worldIds.length; i++) {
278
+ const world = engineState.worlds.get(worldIds[i]!);
279
+ if (world) {
280
+ world.inboundEvents.push(event);
281
+ }
282
+ }
283
+ continue;
284
+ }
285
+
286
+ if (realmId !== undefined) {
287
+ const worldIds = worldsByRealmId.get(realmId);
288
+ if (!worldIds) {
289
+ continue;
290
+ }
291
+ for (let i = 0; i < worldIds.length; i++) {
292
+ const world = engineState.worlds.get(worldIds[i]!);
293
+ if (world) {
294
+ world.inboundEvents.push(event);
295
+ }
296
+ }
297
+ continue;
298
+ }
299
+
300
+ if (targetId !== undefined) {
301
+ const worldIds = worldsByTargetId.get(targetId);
302
+ if (!worldIds) {
303
+ continue;
304
+ }
305
+ for (let i = 0; i < worldIds.length; i++) {
306
+ const world = engineState.worlds.get(worldIds[i]!);
307
+ if (world) {
308
+ world.inboundEvents.push(event);
309
+ }
310
+ }
311
+ continue;
312
+ }
313
+
314
+ // Broadcast non-scoped events (system/audio/ui-async notifications).
315
+ for (const world of engineState.worlds.values()) {
316
+ world.inboundEvents.push(event);
97
317
  }
98
318
  }
99
319
  }
@@ -110,6 +330,11 @@ export function routeResponses(responses: CommandResponseEnvelope[]): void {
110
330
  world.inboundResponses.push(res);
111
331
  }
112
332
  engineState.commandTracker.delete(res.id);
333
+ continue;
334
+ }
335
+ if (engineState.globalCommandTracker.has(res.id)) {
336
+ engineState.globalInboundResponses.push(res);
337
+ engineState.globalCommandTracker.delete(res.id);
113
338
  }
114
339
  }
115
340
  }
@@ -1,6 +1,7 @@
1
1
  import { EngineError } from '../errors';
2
2
  import { engineState, type WorldState } from '../state';
3
3
 
4
+ /** Ensures engine runtime is initialized and not disposed. */
4
5
  export function requireInitialized(): void {
5
6
  if (engineState.status === 'disposed') {
6
7
  throw new EngineError('Disposed', 'Engine has been disposed.');
@@ -10,6 +11,7 @@ export function requireInitialized(): void {
10
11
  }
11
12
  }
12
13
 
14
+ /** Resolves world state or throws when world id is unknown. */
13
15
  export function getWorldOrThrow(worldId: number): WorldState {
14
16
  const world = engineState.worlds.get(worldId);
15
17
  if (!world) {
@@ -18,11 +20,12 @@ export function getWorldOrThrow(worldId: number): WorldState {
18
20
  return world;
19
21
  }
20
22
 
23
+ /** Ensures entity exists in world entity set. */
21
24
  export function ensureEntity(world: WorldState, entityId: number): void {
22
25
  if (!world.entities.has(entityId)) {
23
26
  throw new EngineError(
24
27
  'EntityNotFound',
25
- `Entity ${entityId} not found in World ${world.windowId}.`,
28
+ `Entity ${entityId} not found in World ${world.worldId}.`,
26
29
  );
27
30
  }
28
31
  }
@@ -5,7 +5,6 @@ import type {
5
5
  EngineCmdEnvelope,
6
6
  } from '../../types/cmds';
7
7
  import type { EngineEvent } from '../../types/events';
8
- import { engineState } from '../state';
9
8
 
10
9
  /**
11
10
  * Vulfram Core/ABI Invariants:
@@ -46,6 +45,73 @@ export type CoreCommandBatch = EngineCmdEnvelope[];
46
45
  const packr = new Packr({ useRecords: false });
47
46
  const unpackr = new Unpackr({ useRecords: false });
48
47
 
48
+ function normalizeForMsgpack(value: unknown): unknown {
49
+ if (value === undefined || value === null) return value;
50
+ if (Array.isArray(value)) {
51
+ let out: unknown[] | undefined;
52
+ for (let i = 0; i < value.length; i++) {
53
+ const item = value[i];
54
+ const normalizedItem = normalizeForMsgpack(item);
55
+ const normalized = normalizedItem === undefined ? null : normalizedItem;
56
+ if (!out) {
57
+ if (normalized !== item) {
58
+ out = value.slice(0, i);
59
+ out.push(normalized);
60
+ }
61
+ } else {
62
+ out.push(normalized);
63
+ }
64
+ }
65
+ return out ?? value;
66
+ }
67
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) {
68
+ return value;
69
+ }
70
+ if (typeof value === 'object') {
71
+ const input = value as Record<string, unknown>;
72
+ const entries = Object.entries(input);
73
+ let out: Record<string, unknown> | undefined;
74
+
75
+ for (let i = 0; i < entries.length; i++) {
76
+ const [key, item] = entries[i]!;
77
+ if (item === undefined) continue;
78
+ const normalized = normalizeForMsgpack(item);
79
+ if (normalized === undefined) {
80
+ continue;
81
+ }
82
+ if (!out) {
83
+ if (normalized !== item) {
84
+ out = {};
85
+ for (let j = 0; j < i; j++) {
86
+ const [prevKey, prevItem] = entries[j]!;
87
+ if (prevItem !== undefined) {
88
+ out[prevKey] = prevItem;
89
+ }
90
+ }
91
+ out[key] = normalized;
92
+ }
93
+ } else {
94
+ out[key] = normalized;
95
+ }
96
+ }
97
+ if (!out) {
98
+ for (const [, item] of entries) {
99
+ if (item === undefined) {
100
+ out = {};
101
+ for (const [prevKey, prevItem] of entries) {
102
+ if (prevItem !== undefined) {
103
+ out[prevKey] = prevItem;
104
+ }
105
+ }
106
+ break;
107
+ }
108
+ }
109
+ }
110
+ return out ?? value;
111
+ }
112
+ return value;
113
+ }
114
+
49
115
  /**
50
116
  * Minimal Command Payload Requirement:
51
117
  * Most scene/window commands include a `windowId` so the Core can route them
@@ -60,57 +126,8 @@ export interface BaseCommandArgs {
60
126
  * Serializes a batch of commands into a byte buffer ready for vulframSendQueue.
61
127
  */
62
128
  export function serializeBatch(batch: CoreCommandBatch): TransportBuffer {
63
- if (engineState.flags.debugEnabled) {
64
- const types = batch.map((cmd) => cmd.type);
65
- const hasModel =
66
- types.includes('cmd-model-create') || types.includes('cmd-model-update');
67
- const hasResource = types.some(
68
- (type) =>
69
- type === 'cmd-primitive-geometry-create' ||
70
- type === 'cmd-geometry-create' ||
71
- type === 'cmd-material-create' ||
72
- type === 'cmd-texture-create-from-buffer' ||
73
- type === 'cmd-texture-create-solid-color',
74
- );
75
- if (hasModel || hasResource) {
76
- console.debug('[Debug] BatchTypes', types);
77
- const modelCreate = batch.find((cmd) => cmd.type === 'cmd-model-create');
78
- if (modelCreate) {
79
- console.debug(
80
- '[Debug] ModelCreatePayload',
81
- JSON.stringify(modelCreate.content),
82
- );
83
- }
84
- const primitiveGeo = batch.find(
85
- (cmd) => cmd.type === 'cmd-primitive-geometry-create',
86
- );
87
- if (primitiveGeo) {
88
- console.debug(
89
- '[Debug] GeometryPayload',
90
- JSON.stringify(primitiveGeo.content),
91
- );
92
- }
93
- const materialCreate = batch.find(
94
- (cmd) => cmd.type === 'cmd-material-create',
95
- );
96
- if (materialCreate) {
97
- console.debug(
98
- '[Debug] MaterialPayload',
99
- JSON.stringify(materialCreate.content),
100
- );
101
- }
102
- const textureCreate = batch.find(
103
- (cmd) => cmd.type === 'cmd-texture-create-from-buffer',
104
- );
105
- if (textureCreate) {
106
- console.debug(
107
- '[Debug] TexturePayload',
108
- JSON.stringify(textureCreate.content),
109
- );
110
- }
111
- }
112
- }
113
- return packr.pack(batch);
129
+ const normalizedBatch = normalizeForMsgpack(batch) as CoreCommandBatch;
130
+ return packr.pack(normalizedBatch);
114
131
  }
115
132
 
116
133
  /**