@viewscript/renderer 0.1.0-202605140721 → 0.1.0-202605141229
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runtime/__tests__/ffi-dispatcher.test.d.ts +11 -0
- package/dist/runtime/__tests__/ffi-dispatcher.test.js +408 -0
- package/dist/runtime/__tests__/ffi-integration.test.d.ts +9 -0
- package/dist/runtime/__tests__/ffi-integration.test.js +514 -0
- package/dist/runtime/__tests__/wasm-solver-bridge.test.d.ts +9 -0
- package/dist/runtime/__tests__/wasm-solver-bridge.test.js +214 -0
- package/dist/runtime/ffi-dispatcher.d.ts +217 -0
- package/dist/runtime/ffi-dispatcher.js +291 -0
- package/dist/runtime/render-loop.d.ts +46 -1
- package/dist/runtime/render-loop.js +67 -1
- package/dist/runtime/wasm-solver-bridge.d.ts +122 -0
- package/dist/runtime/wasm-solver-bridge.js +168 -0
- package/package.json +1 -1
- package/src/runtime/__tests__/ffi-dispatcher.test.ts +519 -0
- package/src/runtime/__tests__/ffi-integration.test.ts +650 -0
- package/src/runtime/__tests__/wasm-solver-bridge.test.ts +276 -0
- package/src/runtime/ffi-dispatcher.ts +451 -0
- package/src/runtime/render-loop.ts +90 -2
- package/src/runtime/wasm-solver-bridge.ts +268 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FFI Dispatcher for ViewScript Runtime
|
|
3
|
+
*
|
|
4
|
+
* Evaluates pending FFI calls after frame commit (Phase 8) and buffers
|
|
5
|
+
* results for consumption in the next frame's QSnapshot merge (Phase 0-1).
|
|
6
|
+
*
|
|
7
|
+
* ## Design Rationale
|
|
8
|
+
*
|
|
9
|
+
* FFI function execution time is non-deterministic (user-defined functions).
|
|
10
|
+
* By executing after commitFrame(), we:
|
|
11
|
+
* 1. Keep the rendering critical path (Phase 2-7) free of unpredictable latency
|
|
12
|
+
* 2. Utilize the idle time between commitFrame() and the next rAF callback
|
|
13
|
+
* 3. Maintain 1-frame latency for FFI results (Axiom 2: Ouroboros Binding)
|
|
14
|
+
*
|
|
15
|
+
* ## Data Flow
|
|
16
|
+
*
|
|
17
|
+
* ```
|
|
18
|
+
* Frame N:
|
|
19
|
+
* Phase 2: tick() → TickResult { pending_ffi_calls }
|
|
20
|
+
* Phase 8: dispatch(pending_ffi_calls) → buffer results
|
|
21
|
+
*
|
|
22
|
+
* Frame N+1:
|
|
23
|
+
* Phase 0-1: drainResults() → QSnapshot.values merge
|
|
24
|
+
* Phase 2: solver sees FFI results as Q-dimension input
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// =============================================================================
|
|
29
|
+
// Types (mirrors vsc-wasm/src/ffi_bridge.rs)
|
|
30
|
+
// =============================================================================
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* FFI argument types from manifest.
|
|
34
|
+
*/
|
|
35
|
+
export type FfiArg =
|
|
36
|
+
| { type: 'static'; value: unknown }
|
|
37
|
+
| { type: 'q_ref'; name: string }
|
|
38
|
+
| { type: 'entity_coord'; entity_id: number; component: string };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* FFI binding from manifest.
|
|
42
|
+
*/
|
|
43
|
+
export interface FfiBinding {
|
|
44
|
+
ffi_id: number;
|
|
45
|
+
bind_name: string;
|
|
46
|
+
module_path: string;
|
|
47
|
+
export_name: string;
|
|
48
|
+
args: FfiArg[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* FFI trigger from manifest.
|
|
53
|
+
*/
|
|
54
|
+
export interface FfiTrigger {
|
|
55
|
+
trigger_id: number;
|
|
56
|
+
ffi_id: number;
|
|
57
|
+
module_path: string;
|
|
58
|
+
export_name: string;
|
|
59
|
+
condition: {
|
|
60
|
+
kind: string;
|
|
61
|
+
entity_a?: number;
|
|
62
|
+
entity_b?: number;
|
|
63
|
+
};
|
|
64
|
+
args: FfiArg[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Complete FFI manifest.
|
|
69
|
+
*/
|
|
70
|
+
export interface FfiManifest {
|
|
71
|
+
version: number;
|
|
72
|
+
entity_map: Record<string, number>;
|
|
73
|
+
bindings: FfiBinding[];
|
|
74
|
+
triggers: FfiTrigger[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Pending FFI call from WASM tick() result.
|
|
79
|
+
*/
|
|
80
|
+
export interface PendingFfiCall {
|
|
81
|
+
ffi_id: number;
|
|
82
|
+
trigger_id?: number;
|
|
83
|
+
args: unknown[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Result of an FFI call, ready for QSnapshot merge.
|
|
88
|
+
*/
|
|
89
|
+
export interface FfiResult {
|
|
90
|
+
/** Q-variable name to update */
|
|
91
|
+
name: string;
|
|
92
|
+
/** Computed value */
|
|
93
|
+
value: number;
|
|
94
|
+
/** Timestamp for event ordering */
|
|
95
|
+
timestamp: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Registered FFI function entry.
|
|
100
|
+
*/
|
|
101
|
+
interface FfiFunctionEntry {
|
|
102
|
+
ffi_id: number;
|
|
103
|
+
bind_name: string;
|
|
104
|
+
module_path: string;
|
|
105
|
+
export_name: string;
|
|
106
|
+
args: FfiArg[];
|
|
107
|
+
/** Resolved function reference (set by registerModule) */
|
|
108
|
+
fn: ((...args: unknown[]) => unknown) | null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// =============================================================================
|
|
112
|
+
// FFI Dispatcher
|
|
113
|
+
// =============================================================================
|
|
114
|
+
|
|
115
|
+
export class FfiDispatcher {
|
|
116
|
+
/** Function registry: ffi_id -> entry */
|
|
117
|
+
private registry = new Map<number, FfiFunctionEntry>();
|
|
118
|
+
|
|
119
|
+
/** Pending modules awaiting registration */
|
|
120
|
+
private pendingModules = new Set<string>();
|
|
121
|
+
|
|
122
|
+
/** Buffered results for next frame */
|
|
123
|
+
private resultBuffer: FfiResult[] = [];
|
|
124
|
+
|
|
125
|
+
/** In-flight async calls (prevents duplicate dispatch while awaiting) */
|
|
126
|
+
private inflight = new Set<number>();
|
|
127
|
+
|
|
128
|
+
/** Entity coordinate resolver (injected) */
|
|
129
|
+
private coordResolver: CoordinateResolver | null = null;
|
|
130
|
+
|
|
131
|
+
/** Q-variable resolver (injected) */
|
|
132
|
+
private qResolver: QVariableResolver | null = null;
|
|
133
|
+
|
|
134
|
+
// ===========================================================================
|
|
135
|
+
// Public API
|
|
136
|
+
// ===========================================================================
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Load FFI manifest and build function registry.
|
|
140
|
+
*
|
|
141
|
+
* @param manifest - Parsed FfiManifest JSON
|
|
142
|
+
*/
|
|
143
|
+
loadManifest(manifest: FfiManifest): void {
|
|
144
|
+
this.registry.clear();
|
|
145
|
+
this.pendingModules.clear();
|
|
146
|
+
|
|
147
|
+
// Register bindings
|
|
148
|
+
for (const binding of manifest.bindings) {
|
|
149
|
+
this.registry.set(binding.ffi_id, {
|
|
150
|
+
ffi_id: binding.ffi_id,
|
|
151
|
+
bind_name: binding.bind_name,
|
|
152
|
+
module_path: binding.module_path,
|
|
153
|
+
export_name: binding.export_name,
|
|
154
|
+
args: binding.args,
|
|
155
|
+
fn: null,
|
|
156
|
+
});
|
|
157
|
+
this.pendingModules.add(binding.module_path);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Register triggers (each trigger has its own function reference)
|
|
161
|
+
for (const trigger of manifest.triggers) {
|
|
162
|
+
if (!this.registry.has(trigger.ffi_id)) {
|
|
163
|
+
this.registry.set(trigger.ffi_id, {
|
|
164
|
+
ffi_id: trigger.ffi_id,
|
|
165
|
+
bind_name: `trigger_${trigger.trigger_id}`, // Triggers don't have bind names
|
|
166
|
+
module_path: trigger.module_path,
|
|
167
|
+
export_name: trigger.export_name,
|
|
168
|
+
args: trigger.args,
|
|
169
|
+
fn: null,
|
|
170
|
+
});
|
|
171
|
+
this.pendingModules.add(trigger.module_path);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Register a dynamically imported ESM module.
|
|
178
|
+
*
|
|
179
|
+
* Call this after `import(modulePath)` resolves.
|
|
180
|
+
*
|
|
181
|
+
* @param modulePath - Resolved module path (must match manifest's module_path)
|
|
182
|
+
* @param module - The imported module object
|
|
183
|
+
*/
|
|
184
|
+
registerModule(modulePath: string, module: Record<string, unknown>): void {
|
|
185
|
+
for (const entry of this.registry.values()) {
|
|
186
|
+
if (entry.module_path === modulePath) {
|
|
187
|
+
const fn = module[entry.export_name];
|
|
188
|
+
if (typeof fn === 'function') {
|
|
189
|
+
entry.fn = fn as (...args: unknown[]) => unknown;
|
|
190
|
+
} else {
|
|
191
|
+
console.warn(
|
|
192
|
+
`[FfiDispatcher] Export '${entry.export_name}' from '${modulePath}' is not a function`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
this.pendingModules.delete(modulePath);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Set the coordinate resolver for EntityCoord arguments.
|
|
202
|
+
*/
|
|
203
|
+
setCoordinateResolver(resolver: CoordinateResolver): void {
|
|
204
|
+
this.coordResolver = resolver;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Set the Q-variable resolver for QRef arguments.
|
|
209
|
+
*/
|
|
210
|
+
setQResolver(resolver: QVariableResolver): void {
|
|
211
|
+
this.qResolver = resolver;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Get list of module paths that need to be imported.
|
|
216
|
+
*/
|
|
217
|
+
getPendingModules(): string[] {
|
|
218
|
+
return [...this.pendingModules];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Check if all modules have been registered.
|
|
223
|
+
*/
|
|
224
|
+
isReady(): boolean {
|
|
225
|
+
return this.pendingModules.size === 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Dispatch pending FFI calls (Phase 8).
|
|
230
|
+
*
|
|
231
|
+
* Evaluates JS functions and buffers results. Supports both sync and async
|
|
232
|
+
* functions. For async functions, results are buffered when the Promise
|
|
233
|
+
* resolves (may be multiple frames later).
|
|
234
|
+
*
|
|
235
|
+
* In-flight guard: If an async call for a given ffi_id is still pending,
|
|
236
|
+
* subsequent dispatch requests for the same ffi_id are skipped until the
|
|
237
|
+
* previous call completes.
|
|
238
|
+
*
|
|
239
|
+
* @param pendingCalls - Array of PendingFfiCall from TickResult
|
|
240
|
+
*/
|
|
241
|
+
dispatch(pendingCalls: PendingFfiCall[]): void {
|
|
242
|
+
for (const call of pendingCalls) {
|
|
243
|
+
const entry = this.registry.get(call.ffi_id);
|
|
244
|
+
if (!entry) {
|
|
245
|
+
console.warn(`[FfiDispatcher] Unknown ffi_id: ${call.ffi_id}`);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!entry.fn) {
|
|
250
|
+
console.warn(
|
|
251
|
+
`[FfiDispatcher] Function not registered for ffi_id ${call.ffi_id} ` +
|
|
252
|
+
`(${entry.module_path}::${entry.export_name})`
|
|
253
|
+
);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// In-flight guard: skip if previous async call hasn't completed
|
|
258
|
+
if (this.inflight.has(call.ffi_id)) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
// Resolve arguments
|
|
264
|
+
const resolvedArgs = this.resolveArgs(entry.args, call.args);
|
|
265
|
+
|
|
266
|
+
// Call the function
|
|
267
|
+
const result = entry.fn(...resolvedArgs);
|
|
268
|
+
|
|
269
|
+
// Handle async (Promise) results
|
|
270
|
+
if (result instanceof Promise) {
|
|
271
|
+
this.inflight.add(call.ffi_id);
|
|
272
|
+
|
|
273
|
+
result
|
|
274
|
+
.then((value) => {
|
|
275
|
+
this.bufferResult(entry.bind_name, value);
|
|
276
|
+
})
|
|
277
|
+
.catch((error) => {
|
|
278
|
+
console.error(
|
|
279
|
+
`[FfiDispatcher] Async error in ${entry.export_name}:`,
|
|
280
|
+
error
|
|
281
|
+
);
|
|
282
|
+
})
|
|
283
|
+
.finally(() => {
|
|
284
|
+
this.inflight.delete(call.ffi_id);
|
|
285
|
+
});
|
|
286
|
+
} else {
|
|
287
|
+
// Handle sync results
|
|
288
|
+
this.bufferResult(entry.bind_name, result);
|
|
289
|
+
}
|
|
290
|
+
} catch (error) {
|
|
291
|
+
console.error(
|
|
292
|
+
`[FfiDispatcher] Error calling ${entry.export_name}:`,
|
|
293
|
+
error
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Buffer a result value if it's numeric.
|
|
301
|
+
*/
|
|
302
|
+
private bufferResult(bindName: string, result: unknown): void {
|
|
303
|
+
const timestamp = performance.now();
|
|
304
|
+
|
|
305
|
+
if (typeof result === 'number') {
|
|
306
|
+
this.resultBuffer.push({
|
|
307
|
+
name: bindName,
|
|
308
|
+
value: result,
|
|
309
|
+
timestamp,
|
|
310
|
+
});
|
|
311
|
+
} else if (result !== undefined && result !== null) {
|
|
312
|
+
// Attempt numeric coercion for non-number results
|
|
313
|
+
const numValue = Number(result);
|
|
314
|
+
if (!isNaN(numValue)) {
|
|
315
|
+
this.resultBuffer.push({
|
|
316
|
+
name: bindName,
|
|
317
|
+
value: numValue,
|
|
318
|
+
timestamp,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
// Non-numeric results are silently ignored (side-effect only functions)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Drain buffered results for QSnapshot merge (Phase 0-1).
|
|
327
|
+
*
|
|
328
|
+
* Returns all buffered results and clears the buffer.
|
|
329
|
+
* Call this at the start of the next frame.
|
|
330
|
+
*
|
|
331
|
+
* @returns Array of FfiResult for QSnapshot.values merge
|
|
332
|
+
*/
|
|
333
|
+
drainResults(): FfiResult[] {
|
|
334
|
+
const results = this.resultBuffer;
|
|
335
|
+
this.resultBuffer = [];
|
|
336
|
+
return results;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Get the number of buffered results (for diagnostics).
|
|
341
|
+
*/
|
|
342
|
+
getBufferedCount(): number {
|
|
343
|
+
return this.resultBuffer.length;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Get the number of in-flight async calls (for diagnostics/testing).
|
|
348
|
+
*/
|
|
349
|
+
getInflightCount(): number {
|
|
350
|
+
return this.inflight.size;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Check if a specific ffi_id has an in-flight async call.
|
|
355
|
+
*/
|
|
356
|
+
isInflight(ffiId: number): boolean {
|
|
357
|
+
return this.inflight.has(ffiId);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ===========================================================================
|
|
361
|
+
// Internal Helpers
|
|
362
|
+
// ===========================================================================
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Resolve FfiArg array to concrete values.
|
|
366
|
+
*
|
|
367
|
+
* @param argSpecs - Argument specifications from manifest
|
|
368
|
+
* @param runtimeArgs - Runtime argument overrides from PendingFfiCall
|
|
369
|
+
*/
|
|
370
|
+
private resolveArgs(argSpecs: FfiArg[], runtimeArgs: unknown[]): unknown[] {
|
|
371
|
+
// If runtime args are provided, use them directly (pre-resolved by WASM)
|
|
372
|
+
if (runtimeArgs.length > 0) {
|
|
373
|
+
return runtimeArgs;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Otherwise, resolve from specs
|
|
377
|
+
return argSpecs.map((spec) => this.resolveArg(spec));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Resolve a single FfiArg to its concrete value.
|
|
382
|
+
*/
|
|
383
|
+
private resolveArg(spec: FfiArg): unknown {
|
|
384
|
+
switch (spec.type) {
|
|
385
|
+
case 'static':
|
|
386
|
+
return spec.value;
|
|
387
|
+
|
|
388
|
+
case 'q_ref':
|
|
389
|
+
if (this.qResolver) {
|
|
390
|
+
return this.qResolver.getQValue(spec.name);
|
|
391
|
+
}
|
|
392
|
+
console.warn(`[FfiDispatcher] No Q resolver, cannot resolve ${spec.name}`);
|
|
393
|
+
return 0;
|
|
394
|
+
|
|
395
|
+
case 'entity_coord':
|
|
396
|
+
if (this.coordResolver) {
|
|
397
|
+
return this.coordResolver.getEntityCoord(spec.entity_id, spec.component);
|
|
398
|
+
}
|
|
399
|
+
console.warn(
|
|
400
|
+
`[FfiDispatcher] No coord resolver, cannot resolve entity ${spec.entity_id}.${spec.component}`
|
|
401
|
+
);
|
|
402
|
+
return 0;
|
|
403
|
+
|
|
404
|
+
default:
|
|
405
|
+
console.warn(`[FfiDispatcher] Unknown arg type: ${(spec as FfiArg).type}`);
|
|
406
|
+
return 0;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// =============================================================================
|
|
412
|
+
// Resolver Interfaces
|
|
413
|
+
// =============================================================================
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Interface for resolving entity coordinates.
|
|
417
|
+
*/
|
|
418
|
+
export interface CoordinateResolver {
|
|
419
|
+
/**
|
|
420
|
+
* Get a coordinate component for an entity.
|
|
421
|
+
*
|
|
422
|
+
* @param entityId - Entity ID
|
|
423
|
+
* @param component - Component name ('x', 'y', 'width', 'height')
|
|
424
|
+
* @returns Coordinate value (integer pixels)
|
|
425
|
+
*/
|
|
426
|
+
getEntityCoord(entityId: number, component: string): number;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Interface for resolving Q-variable values.
|
|
431
|
+
*/
|
|
432
|
+
export interface QVariableResolver {
|
|
433
|
+
/**
|
|
434
|
+
* Get current value of a Q-variable.
|
|
435
|
+
*
|
|
436
|
+
* @param name - Q-variable name
|
|
437
|
+
* @returns Current value
|
|
438
|
+
*/
|
|
439
|
+
getQValue(name: string): number;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// =============================================================================
|
|
443
|
+
// Factory Function
|
|
444
|
+
// =============================================================================
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Create a new FFI dispatcher instance.
|
|
448
|
+
*/
|
|
449
|
+
export function createFfiDispatcher(): FfiDispatcher {
|
|
450
|
+
return new FfiDispatcher();
|
|
451
|
+
}
|
|
@@ -42,6 +42,8 @@ import type {
|
|
|
42
42
|
PVectorBounds,
|
|
43
43
|
ChunkId,
|
|
44
44
|
} from '../ast/types';
|
|
45
|
+
import type { FfiDispatcher, PendingFfiCall } from './ffi-dispatcher.js';
|
|
46
|
+
import type { WasmSolverBridge } from './wasm-solver-bridge.js';
|
|
45
47
|
|
|
46
48
|
// =============================================================================
|
|
47
49
|
// Types
|
|
@@ -152,6 +154,26 @@ export class AtomicRenderLoop {
|
|
|
152
154
|
*/
|
|
153
155
|
private eventBuffer: EventBufferInterface | null = null;
|
|
154
156
|
|
|
157
|
+
/**
|
|
158
|
+
* FFI dispatcher for Phase 8 function evaluation.
|
|
159
|
+
*
|
|
160
|
+
* Injected via setFfiDispatcher() to support JS function calls after frame commit.
|
|
161
|
+
*/
|
|
162
|
+
private ffiDispatcher: FfiDispatcher | null = null;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Latest tick result from WASM solver (for Phase 8 FFI dispatch).
|
|
166
|
+
*/
|
|
167
|
+
private latestTickResult: { pending_ffi_calls: PendingFfiCall[] } | null = null;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Concrete solver bridge for FFI result injection (Phase 0.5).
|
|
171
|
+
*
|
|
172
|
+
* This is the concrete type of constraintSolver, used only for
|
|
173
|
+
* injectFfiResults(). The ConstraintSolver interface remains FFI-agnostic.
|
|
174
|
+
*/
|
|
175
|
+
private solverBridge: WasmSolverBridge | null = null;
|
|
176
|
+
|
|
155
177
|
constructor(
|
|
156
178
|
config: Partial<RenderLoopConfig>,
|
|
157
179
|
constraintSolver: ConstraintSolver,
|
|
@@ -221,6 +243,27 @@ export class AtomicRenderLoop {
|
|
|
221
243
|
this.eventBuffer = buffer;
|
|
222
244
|
}
|
|
223
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Set the FFI dispatcher for Phase 8 function evaluation.
|
|
248
|
+
*
|
|
249
|
+
* The FFI dispatcher evaluates JS functions after frame commit and buffers
|
|
250
|
+
* results for the next frame's QSnapshot merge.
|
|
251
|
+
*/
|
|
252
|
+
setFfiDispatcher(dispatcher: FfiDispatcher): void {
|
|
253
|
+
this.ffiDispatcher = dispatcher;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Set the concrete solver bridge for FFI result injection.
|
|
258
|
+
*
|
|
259
|
+
* The solver bridge is the concrete implementation of ConstraintSolver
|
|
260
|
+
* that supports injectFfiResults(). This must be the same instance
|
|
261
|
+
* as the constraintSolver passed to the constructor.
|
|
262
|
+
*/
|
|
263
|
+
setSolverBridge(bridge: WasmSolverBridge): void {
|
|
264
|
+
this.solverBridge = bridge;
|
|
265
|
+
}
|
|
266
|
+
|
|
224
267
|
// ===========================================================================
|
|
225
268
|
// Frame Execution (Atomic Commit)
|
|
226
269
|
// ===========================================================================
|
|
@@ -253,6 +296,22 @@ export class AtomicRenderLoop {
|
|
|
253
296
|
this.eventBuffer.mergeAsyncEvents();
|
|
254
297
|
}
|
|
255
298
|
|
|
299
|
+
// -------------------------------------------------------------------------
|
|
300
|
+
// Phase 0.5: Merge FFI Results from Previous Frame
|
|
301
|
+
// -------------------------------------------------------------------------
|
|
302
|
+
// FFI function results from Phase 8 of the previous frame are drained
|
|
303
|
+
// here and injected into the solver bridge. The bridge will include these
|
|
304
|
+
// values in the QSnapshot when evaluate() is called in Phase 2.
|
|
305
|
+
//
|
|
306
|
+
// This maintains the 1-frame latency required by Axiom 2 (Ouroboros Binding):
|
|
307
|
+
// FFI results flow Q→T→P, never directly into P-dimension.
|
|
308
|
+
if (this.ffiDispatcher && this.solverBridge) {
|
|
309
|
+
const ffiResults = this.ffiDispatcher.drainResults();
|
|
310
|
+
if (ffiResults.length > 0) {
|
|
311
|
+
this.solverBridge.injectFfiResults(ffiResults);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
256
315
|
// -------------------------------------------------------------------------
|
|
257
316
|
// Phase 1: Flush Pending Mutations (Backpressure-Limited)
|
|
258
317
|
// -------------------------------------------------------------------------
|
|
@@ -261,7 +320,11 @@ export class AtomicRenderLoop {
|
|
|
261
320
|
// -------------------------------------------------------------------------
|
|
262
321
|
// Phase 2: Evaluate Constraint Graph
|
|
263
322
|
// -------------------------------------------------------------------------
|
|
264
|
-
const
|
|
323
|
+
const solverResult = this.constraintSolver.evaluate(mutations);
|
|
324
|
+
const pVectorBounds = solverResult.bounds;
|
|
325
|
+
|
|
326
|
+
// Store pending FFI calls for Phase 8 (after commit)
|
|
327
|
+
this.latestTickResult = { pending_ffi_calls: solverResult.pendingFfiCalls };
|
|
265
328
|
|
|
266
329
|
// -------------------------------------------------------------------------
|
|
267
330
|
// Phase 3: Topology-Preserving Rounding (with Error Distribution)
|
|
@@ -288,6 +351,19 @@ export class AtomicRenderLoop {
|
|
|
288
351
|
// -------------------------------------------------------------------------
|
|
289
352
|
this.commitFrame(drawCommands, domMutations);
|
|
290
353
|
|
|
354
|
+
// -------------------------------------------------------------------------
|
|
355
|
+
// Phase 8: FFI Dispatch (Post-Commit)
|
|
356
|
+
// -------------------------------------------------------------------------
|
|
357
|
+
// FFI functions are evaluated AFTER frame commit to keep the rendering
|
|
358
|
+
// critical path (Phase 2-7) free of unpredictable latency. Results are
|
|
359
|
+
// buffered for consumption in the next frame's Phase 0.5.
|
|
360
|
+
//
|
|
361
|
+
// This utilizes the idle time between commitFrame() and the next rAF.
|
|
362
|
+
if (this.ffiDispatcher && this.latestTickResult) {
|
|
363
|
+
this.ffiDispatcher.dispatch(this.latestTickResult.pending_ffi_calls);
|
|
364
|
+
this.latestTickResult = null;
|
|
365
|
+
}
|
|
366
|
+
|
|
291
367
|
// -------------------------------------------------------------------------
|
|
292
368
|
// Swap Buffers
|
|
293
369
|
// -------------------------------------------------------------------------
|
|
@@ -470,8 +546,20 @@ function boundsEqual(a: RasterBounds, b: RasterBounds): boolean {
|
|
|
470
546
|
*
|
|
471
547
|
* This ensures the constraint graph is the single source of truth.
|
|
472
548
|
*/
|
|
549
|
+
/**
|
|
550
|
+
* Result of constraint solver evaluation.
|
|
551
|
+
*
|
|
552
|
+
* Contains both P-dimension bounds and pending FFI calls from WASM tick().
|
|
553
|
+
*/
|
|
554
|
+
interface SolverEvaluationResult {
|
|
555
|
+
/** P-dimension bounds for all entities */
|
|
556
|
+
bounds: Map<EntityId, PVectorBounds>;
|
|
557
|
+
/** Pending FFI calls from trigger evaluation (may be empty) */
|
|
558
|
+
pendingFfiCalls: PendingFfiCall[];
|
|
559
|
+
}
|
|
560
|
+
|
|
473
561
|
interface ConstraintSolver {
|
|
474
|
-
evaluate(mutations: TStateMutation[]):
|
|
562
|
+
evaluate(mutations: TStateMutation[]): SolverEvaluationResult;
|
|
475
563
|
}
|
|
476
564
|
|
|
477
565
|
interface TopologyRounder {
|