@vulfram/engine 0.14.8-alpha → 0.19.2-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 (63) hide show
  1. package/README.md +106 -0
  2. package/package.json +60 -4
  3. package/src/core.ts +14 -0
  4. package/src/ecs.ts +1 -0
  5. package/src/engine/api.ts +222 -24
  6. package/src/engine/bridge/dispatch.ts +260 -40
  7. package/src/engine/bridge/guards.ts +4 -1
  8. package/src/engine/bridge/protocol.ts +69 -52
  9. package/src/engine/ecs/components.ts +340 -0
  10. package/src/engine/ecs/index.ts +3 -518
  11. package/src/engine/ecs/intents.ts +184 -0
  12. package/src/engine/ecs/systems.ts +26 -0
  13. package/src/engine/intents/store.ts +72 -0
  14. package/src/engine/state.ts +136 -5
  15. package/src/engine/systems/command-intent.ts +159 -14
  16. package/src/engine/systems/constraint-solve.ts +167 -0
  17. package/src/engine/systems/core-command-builder.ts +9 -268
  18. package/src/engine/systems/diagnostics.ts +20 -29
  19. package/src/engine/systems/index.ts +3 -1
  20. package/src/engine/systems/input-mirror.ts +257 -21
  21. package/src/engine/systems/resource-upload.ts +108 -58
  22. package/src/engine/systems/response-decode.ts +86 -15
  23. package/src/engine/systems/scene-sync.ts +305 -0
  24. package/src/engine/systems/ui-bridge.ts +381 -0
  25. package/src/engine/systems/utils.ts +86 -1
  26. package/src/engine/systems/world-lifecycle.ts +43 -114
  27. package/src/engine/window/manager.ts +168 -0
  28. package/src/engine/world/entities.ts +998 -91
  29. package/src/engine/world/mount.ts +195 -0
  30. package/src/engine/world/types.ts +71 -0
  31. package/src/engine/world/world-ui.ts +313 -0
  32. package/src/engine/world/world3d.ts +529 -0
  33. package/src/helpers/collision.ts +487 -0
  34. package/src/helpers/index.ts +2 -0
  35. package/src/helpers/raycast.ts +442 -0
  36. package/src/index.ts +30 -1
  37. package/src/mount.ts +2 -0
  38. package/src/types/cmds/audio.ts +73 -48
  39. package/src/types/cmds/camera.ts +12 -8
  40. package/src/types/cmds/environment.ts +9 -3
  41. package/src/types/cmds/geometry.ts +15 -16
  42. package/src/types/cmds/index.ts +234 -162
  43. package/src/types/cmds/input.ts +39 -0
  44. package/src/types/cmds/light.ts +12 -11
  45. package/src/types/cmds/material.ts +19 -21
  46. package/src/types/cmds/model.ts +17 -15
  47. package/src/types/cmds/realm.ts +23 -0
  48. package/src/types/cmds/system.ts +29 -0
  49. package/src/types/cmds/target.ts +96 -0
  50. package/src/types/cmds/texture.ts +13 -3
  51. package/src/types/cmds/ui.ts +220 -0
  52. package/src/types/cmds/window.ts +41 -204
  53. package/src/types/events/index.ts +4 -1
  54. package/src/types/events/keyboard.ts +2 -2
  55. package/src/types/events/pointer.ts +85 -13
  56. package/src/types/events/system.ts +188 -30
  57. package/src/types/events/ui.ts +21 -0
  58. package/src/types/index.ts +1 -0
  59. package/src/types/json.ts +15 -0
  60. package/src/window.ts +8 -0
  61. package/src/world-ui.ts +2 -0
  62. package/src/world3d.ts +10 -0
  63. package/tsconfig.json +0 -29
@@ -7,6 +7,60 @@ import { getWorldOrThrow, requireInitialized } from './guards';
7
7
  * Limits for Backpressure management.
8
8
  */
9
9
  const MAX_BATCH_COMMANDS = 2048;
10
+ const MAX_BATCH_ESTIMATED_BYTES = 60 * 1024;
11
+
12
+ function estimateSerializedBytes(value: unknown): number {
13
+ if (value === null || value === undefined) return 1;
14
+ const valueType = typeof value;
15
+ if (valueType === 'boolean') return 1;
16
+ if (valueType === 'number') return 8;
17
+ if (valueType === 'string') return 2 + (value as string).length * 2;
18
+ if (ArrayBuffer.isView(value)) return (value as ArrayBufferView).byteLength + 8;
19
+ if (value instanceof ArrayBuffer) return value.byteLength + 8;
20
+
21
+ if (Array.isArray(value)) {
22
+ let total = 8;
23
+ for (let i = 0; i < value.length; i++) {
24
+ total += estimateSerializedBytes(value[i]);
25
+ }
26
+ return total;
27
+ }
28
+
29
+ if (valueType === 'object') {
30
+ const record = value as Record<string, unknown>;
31
+ let total = 8;
32
+ for (const [key, nested] of Object.entries(record)) {
33
+ if (nested === undefined) continue;
34
+ total += 2 + key.length * 2;
35
+ total += estimateSerializedBytes(nested);
36
+ }
37
+ return total;
38
+ }
39
+
40
+ return 4;
41
+ }
42
+
43
+ function estimateEnvelopeBytes(envelope: { id: number; type: string; content: unknown }): number {
44
+ return 24 + envelope.type.length * 2 + estimateSerializedBytes(envelope.content);
45
+ }
46
+
47
+ function maybeCompactQueue<T>(queue: T[], head: number): number {
48
+ if (head <= 0) return head;
49
+ if (head === queue.length) {
50
+ queue.length = 0;
51
+ return 0;
52
+ }
53
+ if (head >= 1024 && head * 2 >= queue.length) {
54
+ queue.copyWithin(0, head);
55
+ queue.length -= head;
56
+ return 0;
57
+ }
58
+ return head;
59
+ }
60
+
61
+ export function markRoutingIndexDirty(): void {
62
+ engineState.routingIndex.dirty = true;
63
+ }
10
64
 
11
65
  /**
12
66
  * Enqueues a command to be sent to the core in the next tick.
@@ -21,13 +75,45 @@ export function enqueueCommand<T extends EngineCmd['type']>(
21
75
  const world = getWorldOrThrow(worldId);
22
76
  const id = engineState.nextCommandId++;
23
77
 
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;
78
+ // Contract: realmId resolves from world core realm; windowId may default to primary binding.
79
+ const typedContent = {
80
+ ...(content as Record<string, unknown>),
81
+ } as Record<string, unknown>;
82
+ if (
83
+ 'windowId' in typedContent &&
84
+ typedContent.windowId == null &&
85
+ world.primaryWindowId !== undefined
86
+ ) {
87
+ typedContent.windowId = world.primaryWindowId;
88
+ }
89
+ if ('realmId' in typedContent && typedContent.realmId == null) {
90
+ // Avoid host-side readiness guards: keep logical-id flow stable.
91
+ // Core validates realm existence and may apply internal fallbacks.
92
+ typedContent.realmId = world.coreRealmId ?? world.worldId;
28
93
  }
29
94
 
30
- world.pendingCommands.push({ id, type, content });
95
+ world.pendingCommands.push({
96
+ id,
97
+ type,
98
+ content: typedContent as unknown as Extract<
99
+ EngineCmd,
100
+ { type: T }
101
+ >['content'],
102
+ });
103
+ return id;
104
+ }
105
+
106
+ /**
107
+ * Enqueues a command not scoped to a specific world (window management, etc).
108
+ */
109
+ export function enqueueGlobalCommand<T extends EngineCmd['type']>(
110
+ type: T,
111
+ content: Extract<EngineCmd, { type: T }>['content'],
112
+ ): number {
113
+ requireInitialized();
114
+ const id = engineState.nextCommandId++;
115
+ engineState.globalCommandTracker.add(id);
116
+ engineState.globalPendingCommands.push({ id, type, content });
31
117
  return id;
32
118
  }
33
119
 
@@ -37,63 +123,192 @@ export function enqueueCommand<T extends EngineCmd['type']>(
37
123
  */
38
124
  export function collectCommands(): void {
39
125
  let collectedCount = 0;
126
+ let collectedBytes = 0;
127
+
128
+ const globalAvailable =
129
+ engineState.globalPendingCommands.length - engineState.globalPendingCommandsHead;
130
+ if (globalAvailable > 0) {
131
+ const start = engineState.globalPendingCommandsHead;
132
+ let end = start;
133
+ while (
134
+ end < engineState.globalPendingCommands.length &&
135
+ collectedCount < MAX_BATCH_COMMANDS &&
136
+ collectedBytes < MAX_BATCH_ESTIMATED_BYTES
137
+ ) {
138
+ const cmd = engineState.globalPendingCommands[end]!;
139
+ const estimateBytes = estimateEnvelopeBytes(cmd);
140
+ if (collectedBytes > 0 && collectedBytes + estimateBytes > MAX_BATCH_ESTIMATED_BYTES) {
141
+ break;
142
+ }
143
+ engineState.commandBatch.push(cmd);
144
+ collectedBytes += estimateBytes;
145
+ collectedCount++;
146
+ end++;
147
+ }
148
+ engineState.globalPendingCommandsHead = end;
149
+ engineState.globalPendingCommandsHead = maybeCompactQueue(
150
+ engineState.globalPendingCommands,
151
+ engineState.globalPendingCommandsHead,
152
+ );
153
+ }
40
154
 
41
155
  for (const [worldId, world] of engineState.worlds) {
42
- if (collectedCount >= MAX_BATCH_COMMANDS) {
156
+ if (
157
+ collectedCount >= MAX_BATCH_COMMANDS ||
158
+ collectedBytes >= MAX_BATCH_ESTIMATED_BYTES
159
+ ) {
43
160
  // Backpressure: If we reached the limit, stop collecting for this frame.
44
161
  // Remaining commands will wait for the next frame.
45
162
  break;
46
163
  }
47
164
 
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) {
165
+ const worldAvailable = world.pendingCommands.length - world.pendingCommandsHead;
166
+ if (worldAvailable > 0) {
167
+ const start = world.pendingCommandsHead;
168
+ let end = start;
169
+ while (
170
+ end < world.pendingCommands.length &&
171
+ collectedCount < MAX_BATCH_COMMANDS &&
172
+ collectedBytes < MAX_BATCH_ESTIMATED_BYTES
173
+ ) {
174
+ const cmd = world.pendingCommands[end]!;
175
+ const estimateBytes = estimateEnvelopeBytes(cmd);
176
+ if (collectedBytes > 0 && collectedBytes + estimateBytes > MAX_BATCH_ESTIMATED_BYTES) {
177
+ break;
178
+ }
58
179
  engineState.commandBatch.push(cmd);
59
180
  engineState.commandTracker.set(cmd.id, worldId);
181
+ collectedBytes += estimateBytes;
182
+ collectedCount++;
183
+ end++;
60
184
  }
61
- collectedCount += amountToTake;
185
+ world.pendingCommandsHead = end;
186
+ world.pendingCommandsHead = maybeCompactQueue(
187
+ world.pendingCommands,
188
+ world.pendingCommandsHead,
189
+ );
62
190
  }
63
191
  }
64
192
  }
65
193
 
194
+ function ensureRoutingIndex(): void {
195
+ if (!engineState.routingIndex.dirty) return;
196
+
197
+ const byWindowId = new Map<number, number[]>();
198
+ const byRealmId = new Map<number, number[]>();
199
+ const byTargetId = new Map<number, number[]>();
200
+
201
+ for (const [worldId, world] of engineState.worlds) {
202
+ for (const boundWindowId of world.boundWindowIds) {
203
+ const ids = byWindowId.get(boundWindowId);
204
+ if (ids) ids.push(worldId);
205
+ else byWindowId.set(boundWindowId, [worldId]);
206
+ }
207
+
208
+ if (typeof world.coreRealmId === 'number') {
209
+ const ids = byRealmId.get(world.coreRealmId);
210
+ if (ids) ids.push(worldId);
211
+ else byRealmId.set(world.coreRealmId, [worldId]);
212
+ }
213
+
214
+ for (const targetId of world.targetLayerBindings.keys()) {
215
+ const ids = byTargetId.get(targetId);
216
+ if (ids) ids.push(worldId);
217
+ else byTargetId.set(targetId, [worldId]);
218
+ }
219
+ }
220
+
221
+ engineState.routingIndex.byWindowId = byWindowId;
222
+ engineState.routingIndex.byRealmId = byRealmId;
223
+ engineState.routingIndex.byTargetId = byTargetId;
224
+ engineState.routingIndex.dirty = false;
225
+ }
226
+
227
+ function extractEventScopeIds(event: EngineEvent): {
228
+ windowId?: number;
229
+ realmId?: number;
230
+ targetId?: number;
231
+ } {
232
+ const content = event.content as
233
+ | { data?: Record<string, unknown> }
234
+ | Record<string, unknown>;
235
+ const scopedDataCandidate =
236
+ 'data' in content && content.data && typeof content.data === 'object'
237
+ ? content.data
238
+ : content;
239
+
240
+ if (!scopedDataCandidate || typeof scopedDataCandidate !== 'object') {
241
+ return {};
242
+ }
243
+ const scopedData = scopedDataCandidate as Record<string, unknown>;
244
+
245
+ const windowId =
246
+ typeof scopedData.windowId === 'number' ? scopedData.windowId : undefined;
247
+ const realmId =
248
+ typeof scopedData.realmId === 'number' ? scopedData.realmId : undefined;
249
+ const targetId =
250
+ typeof scopedData.targetId === 'number' ? scopedData.targetId : undefined;
251
+
252
+ return { windowId, realmId, targetId };
253
+ }
254
+
66
255
  /**
67
256
  * Routes core events to their respective worlds based on windowId.
68
257
  */
69
258
  export function routeEvents(events: EngineEvent[]): void {
259
+ ensureRoutingIndex();
260
+ const worldsByWindowId = engineState.routingIndex.byWindowId;
261
+ const worldsByRealmId = engineState.routingIndex.byRealmId;
262
+ const worldsByTargetId = engineState.routingIndex.byTargetId;
263
+
70
264
  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
- }
265
+ const { windowId, realmId, targetId } = extractEventScopeIds(event);
89
266
 
90
267
  if (windowId !== undefined) {
91
- const world = engineState.worlds.get(windowId);
92
- if (world) {
93
- world.inboundEvents.push(event);
268
+ const worldIds = worldsByWindowId.get(windowId);
269
+ if (!worldIds) {
270
+ continue;
94
271
  }
95
- } else {
96
- // Global events (if any) could be handled here
272
+ for (let i = 0; i < worldIds.length; i++) {
273
+ const world = engineState.worlds.get(worldIds[i]!);
274
+ if (world) {
275
+ world.inboundEvents.push(event);
276
+ }
277
+ }
278
+ continue;
279
+ }
280
+
281
+ if (realmId !== undefined) {
282
+ const worldIds = worldsByRealmId.get(realmId);
283
+ if (!worldIds) {
284
+ continue;
285
+ }
286
+ for (let i = 0; i < worldIds.length; i++) {
287
+ const world = engineState.worlds.get(worldIds[i]!);
288
+ if (world) {
289
+ world.inboundEvents.push(event);
290
+ }
291
+ }
292
+ continue;
293
+ }
294
+
295
+ if (targetId !== undefined) {
296
+ const worldIds = worldsByTargetId.get(targetId);
297
+ if (!worldIds) {
298
+ continue;
299
+ }
300
+ for (let i = 0; i < worldIds.length; i++) {
301
+ const world = engineState.worlds.get(worldIds[i]!);
302
+ if (world) {
303
+ world.inboundEvents.push(event);
304
+ }
305
+ }
306
+ continue;
307
+ }
308
+
309
+ // Broadcast non-scoped events (system/audio/ui-async notifications).
310
+ for (const world of engineState.worlds.values()) {
311
+ world.inboundEvents.push(event);
97
312
  }
98
313
  }
99
314
  }
@@ -110,6 +325,11 @@ export function routeResponses(responses: CommandResponseEnvelope[]): void {
110
325
  world.inboundResponses.push(res);
111
326
  }
112
327
  engineState.commandTracker.delete(res.id);
328
+ continue;
329
+ }
330
+ if (engineState.globalCommandTracker.has(res.id)) {
331
+ engineState.globalInboundResponses.push(res);
332
+ engineState.globalCommandTracker.delete(res.id);
113
333
  }
114
334
  }
115
335
  }
@@ -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
  /**