@forgeax/engine-app 0.1.4 → 0.1.7

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 (148) hide show
  1. package/README.md +20 -14
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/create-app-error-retention.integration.test.d.ts +2 -0
  4. package/dist/__tests__/create-app-error-retention.integration.test.d.ts.map +1 -0
  5. package/dist/__tests__/create-app-physics-plugin-failure.integration.test.d.ts +2 -0
  6. package/dist/__tests__/create-app-physics-plugin-failure.integration.test.d.ts.map +1 -0
  7. package/dist/animation-asset-lookup.d.ts +1 -2
  8. package/dist/animation-asset-lookup.d.ts.map +1 -1
  9. package/dist/browser-frame-signal.d.ts +17 -0
  10. package/dist/browser-frame-signal.d.ts.map +1 -0
  11. package/dist/create-app.d.ts +31 -10
  12. package/dist/create-app.d.ts.map +1 -1
  13. package/dist/engine-worker-runtime.mjs +72 -129
  14. package/dist/engine-worker-runtime.mjs.map +1 -1
  15. package/dist/errors.d.ts +3 -12
  16. package/dist/errors.d.ts.map +1 -1
  17. package/dist/execution/attached-world-swap.d.ts +1 -12
  18. package/dist/execution/attached-world-swap.d.ts.map +1 -1
  19. package/dist/execution/bootstrap-url.d.ts +5 -0
  20. package/dist/execution/bootstrap-url.d.ts.map +1 -0
  21. package/dist/execution/control.d.ts +0 -2
  22. package/dist/execution/control.d.ts.map +1 -1
  23. package/dist/execution/engine-worker.d.ts +1 -0
  24. package/dist/execution/engine-worker.d.ts.map +1 -1
  25. package/dist/execution/host-controller.d.ts.map +1 -1
  26. package/dist/execution/index.d.ts +1 -1
  27. package/dist/execution/index.d.ts.map +1 -1
  28. package/dist/execution/protocol.d.ts +4 -1
  29. package/dist/execution/protocol.d.ts.map +1 -1
  30. package/dist/execution/types.d.ts +17 -1
  31. package/dist/execution/types.d.ts.map +1 -1
  32. package/dist/game-context.d.ts +1 -1
  33. package/dist/game-context.d.ts.map +1 -1
  34. package/dist/index.d.ts +5 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.mjs +357 -359
  37. package/dist/index.mjs.map +1 -1
  38. package/dist/internal/assembled-engine-profile.d.ts.map +1 -1
  39. package/dist/internal/assets-world-plugin.d.ts +3 -17
  40. package/dist/internal/assets-world-plugin.d.ts.map +1 -1
  41. package/dist/internal/browser-remote-bridge.d.ts +1 -2
  42. package/dist/internal/browser-remote-bridge.d.ts.map +1 -1
  43. package/dist/internal/browser-rhi-debug-runtime.d.ts +1 -1
  44. package/dist/internal/browser-rhi-debug-runtime.d.ts.map +1 -1
  45. package/dist/internal/debug-draw.d.ts +9 -0
  46. package/dist/internal/debug-draw.d.ts.map +1 -0
  47. package/dist/internal/engine-profile-common.d.ts +2 -2
  48. package/dist/internal/engine-profile-common.d.ts.map +1 -1
  49. package/dist/internal/frame-loop.d.ts +7 -4
  50. package/dist/internal/frame-loop.d.ts.map +1 -1
  51. package/dist/internal/main-engine-profile.d.ts +4 -0
  52. package/dist/internal/main-engine-profile.d.ts.map +1 -1
  53. package/dist/internal/remote-serve-flag.d.ts +3 -7
  54. package/dist/internal/remote-serve-flag.d.ts.map +1 -1
  55. package/dist/internal/rhi-capture.d.ts +1 -1
  56. package/dist/internal/rhi-capture.d.ts.map +1 -1
  57. package/dist/internal/worker-engine-profile.d.ts +2 -0
  58. package/dist/internal/worker-engine-profile.d.ts.map +1 -1
  59. package/dist/renderer-plugin.d.ts +25 -0
  60. package/dist/renderer-plugin.d.ts.map +1 -0
  61. package/dist/tool-preview/bootstrap.d.ts.map +1 -1
  62. package/dist/types.d.ts +21 -61
  63. package/dist/types.d.ts.map +1 -1
  64. package/package.json +25 -26
  65. package/src/__tests__/callback-inference.test-d.ts +7 -3
  66. package/src/__tests__/consumer-migration.unit.test.ts +13 -0
  67. package/src/__tests__/create-app-error-retention.integration.test.ts +125 -0
  68. package/src/__tests__/create-app-features.test.ts +21 -10
  69. package/src/__tests__/create-app-listener-sync.test.ts +23 -19
  70. package/src/__tests__/create-app-physics-plugin-failure.integration.test.ts +142 -0
  71. package/src/__tests__/create-app-stop.test.ts +4 -12
  72. package/src/__tests__/create-app.test-d.ts +12 -11
  73. package/src/__tests__/create-app.test.ts +1 -60
  74. package/src/__tests__/create-renderer-error.integration.test.ts +3 -2
  75. package/src/__tests__/create-renderer-lifecycle.integration.test.ts +10 -5
  76. package/src/__tests__/draw-source-injected-world-update-visibility.test.ts +7 -13
  77. package/src/__tests__/draw-source-single-world-regression.test.ts +23 -24
  78. package/src/__tests__/engine-worker-bootstrap-channel.unit.test.ts +8 -0
  79. package/src/__tests__/engine-worker-world-swap.unit.test.ts +15 -22
  80. package/src/__tests__/errors-pointer-lock-failed.test.ts +4 -7
  81. package/src/__tests__/execution-bootstrap-isolation.unit.test.ts +5 -6
  82. package/src/__tests__/execution-frame-order.unit.test.ts +2 -2
  83. package/src/__tests__/execution-frame-protocol.unit.test.ts +3 -4
  84. package/src/__tests__/execution-public-api.test-d.ts +4 -0
  85. package/src/__tests__/frame-loop-time-elapsed.test.ts +1 -1
  86. package/src/__tests__/frame-loop-time.unit.test.ts +1 -1
  87. package/src/__tests__/frame-loop-world-array.test.ts +82 -73
  88. package/src/__tests__/plugin-profiles.unit.test.ts +3 -3
  89. package/src/__tests__/profiler-default-off.test.ts +1 -1
  90. package/src/__tests__/profiler-frame-token.test.ts +17 -30
  91. package/src/__tests__/profiler-phase-integration.test.ts +15 -27
  92. package/src/__tests__/surface-handoff.test.ts +1 -4
  93. package/src/__tests__/visibility-remote.integration.test.ts +2 -5
  94. package/src/__tests__/worker-execution-stop.test.ts +64 -7
  95. package/src/animation-asset-lookup.ts +2 -2
  96. package/src/browser-frame-signal.ts +44 -0
  97. package/src/create-app.ts +208 -295
  98. package/src/errors.ts +27 -39
  99. package/src/execution/attached-world-swap.ts +4 -38
  100. package/src/execution/bootstrap-url.ts +25 -0
  101. package/src/execution/control.ts +3 -7
  102. package/src/execution/engine-worker-runtime.ts +23 -40
  103. package/src/execution/engine-worker.ts +2 -0
  104. package/src/execution/host-controller.ts +8 -2
  105. package/src/execution/index.ts +1 -0
  106. package/src/execution/protocol.ts +4 -1
  107. package/src/execution/types.ts +18 -1
  108. package/src/game-context.ts +1 -1
  109. package/src/index.ts +18 -4
  110. package/src/internal/assembled-engine-profile.ts +3 -11
  111. package/src/internal/assets-world-plugin.ts +7 -53
  112. package/src/internal/browser-remote-bridge.ts +2 -3
  113. package/src/internal/browser-rhi-debug-runtime.ts +1 -1
  114. package/src/internal/debug-draw.ts +38 -0
  115. package/src/internal/engine-profile-common.ts +2 -3
  116. package/src/internal/frame-loop.ts +77 -63
  117. package/src/internal/main-engine-profile.ts +21 -17
  118. package/src/internal/remote-serve-flag.ts +5 -14
  119. package/src/internal/rhi-capture.ts +1 -1
  120. package/src/internal/worker-engine-profile.ts +10 -10
  121. package/src/renderer-plugin.ts +96 -0
  122. package/src/tool-preview/bootstrap.ts +1 -14
  123. package/src/types.ts +22 -77
  124. package/src/vite-env.d.ts +0 -2
  125. package/dist/__tests__/app-rebuild.test.d.ts +0 -2
  126. package/dist/__tests__/app-rebuild.test.d.ts.map +0 -1
  127. package/dist/__tests__/asset-registry-types.test-d.d.ts +0 -2
  128. package/dist/__tests__/asset-registry-types.test-d.d.ts.map +0 -1
  129. package/dist/__tests__/assets-runtime-assembly.integration.test.d.ts +0 -2
  130. package/dist/__tests__/assets-runtime-assembly.integration.test.d.ts.map +0 -1
  131. package/dist/__tests__/draw-source-headless-full-chain.test.d.ts +0 -2
  132. package/dist/__tests__/draw-source-headless-full-chain.test.d.ts.map +0 -1
  133. package/dist/__tests__/ecs-import.unit.test.d.ts +0 -2
  134. package/dist/__tests__/ecs-import.unit.test.d.ts.map +0 -1
  135. package/dist/__tests__/execution-world-health-owner.test-d.d.ts +0 -2
  136. package/dist/__tests__/execution-world-health-owner.test-d.d.ts.map +0 -1
  137. package/dist/__tests__/remote-serve-flag.test.d.ts +0 -2
  138. package/dist/__tests__/remote-serve-flag.test.d.ts.map +0 -1
  139. package/dist/assets-runtime-assembly.d.ts +0 -19
  140. package/dist/assets-runtime-assembly.d.ts.map +0 -1
  141. package/src/__tests__/app-rebuild.test.ts +0 -31
  142. package/src/__tests__/asset-registry-types.test-d.ts +0 -16
  143. package/src/__tests__/assets-runtime-assembly.integration.test.ts +0 -68
  144. package/src/__tests__/draw-source-headless-full-chain.test.ts +0 -226
  145. package/src/__tests__/ecs-import.unit.test.ts +0 -40
  146. package/src/__tests__/execution-world-health-owner.test-d.ts +0 -9
  147. package/src/__tests__/remote-serve-flag.test.ts +0 -24
  148. package/src/assets-runtime-assembly.ts +0 -70
package/src/create-app.ts CHANGED
@@ -23,40 +23,31 @@
23
23
  // inherits .tagName from HTMLElement, so the property test cleanly
24
24
  // separates the canvas argument from the AppAssembleArgs plain object.
25
25
 
26
- import {
27
- type AssetRegistry,
28
- type CatalogSource,
29
- createAssetRegistry,
30
- createCatalogSource,
31
- } from '@forgeax/engine-assets-runtime';
32
- import { getAssetRegistryResolver } from '@forgeax/engine-assets-runtime/internal';
26
+ import type { AssetRegistry } from '@forgeax/engine-assets-runtime';
33
27
  import type { AudioBackend } from '@forgeax/engine-audio';
34
28
  import type { DebugDraw } from '@forgeax/engine-debug-draw';
35
29
  import { createWorldContext, Update, World } from '@forgeax/engine-ecs';
36
30
  import type { InputBackend } from '@forgeax/engine-input';
37
31
  import type { Context, Plugin } from '@forgeax/engine-plugin';
38
- import type { Renderer, RendererError } from '@forgeax/engine-render';
39
- import { loadRhiPack } from '@forgeax/engine-render/internal';
32
+ import type { RenderError } from '@forgeax/engine-render';
33
+ import { CAMERA_PROJECTION_PERSPECTIVE, Camera, type Renderer } from '@forgeax/engine-render';
34
+ import type { RendererHostAssembly } from '@forgeax/engine-render/internal/construct-renderer';
40
35
  import { RhiError } from '@forgeax/engine-rhi';
41
- import type {
42
- CaptureFrameOptions,
43
- CreateShaderModuleFn,
44
- RecorderAttachment,
36
+ import {
37
+ attachRecorder,
38
+ type CaptureFrameOptions,
39
+ type RecorderAttachment,
45
40
  } from '@forgeax/engine-rhi-debug';
46
41
  import * as engineRuntimeModule from '@forgeax/engine-runtime';
47
- import { createRenderer, EngineEnvironmentError } from '@forgeax/engine-runtime';
48
- import { type AssetDecoderContributionRef, err, ok, type Result } from '@forgeax/engine-types';
42
+ import { EngineEnvironmentError } from '@forgeax/engine-runtime';
43
+ import {
44
+ constructRuntimeRendererHost,
45
+ loadRhiPack,
46
+ } from '@forgeax/engine-runtime/internal/renderer-host';
47
+ import { err, ok, type Result } from '@forgeax/engine-types';
49
48
 
50
49
  import { createAnimationPayloadLookup } from './animation-asset-lookup';
51
-
52
- export { syncCameraAspect } from './canvas-policy';
53
-
54
- import {
55
- type AssetRuntimeAssembly,
56
- type AssetRuntimeAssemblyError,
57
- assembleAssetRuntime,
58
- } from './assets-runtime-assembly';
59
- import { syncCameraAspect } from './canvas-policy';
50
+ import { publishBrowserFrameSubmitted, resetBrowserFrameSubmitted } from './browser-frame-signal';
60
51
  import type { AppErrorCode, AppErrorDetailFor } from './errors';
61
52
  import { APP_ERROR_HINTS, APP_EXPECTED, AppError } from './errors';
62
53
  import {
@@ -69,6 +60,7 @@ import {
69
60
  selectExecutionTier,
70
61
  unavailableExecutionCapabilities,
71
62
  } from './execution';
63
+ import { normalizeExecutionBootstrapUrl } from './execution/bootstrap-url';
72
64
  import { createLocalExecutionControl } from './execution/control';
73
65
  import { createWorkerExecutionApp } from './execution/host-controller';
74
66
  import { assembledEngineProfile } from './internal/assembled-engine-profile';
@@ -86,6 +78,7 @@ import {
86
78
  type RhiCapture,
87
79
  } from './internal/rhi-capture';
88
80
  import { resolveRhiDebugFlag } from './internal/rhi-debug-flag';
81
+ import { createRenderFeatureHost, type RenderFeatureHost } from './renderer-plugin';
89
82
  import type {
90
83
  App,
91
84
  AppAssembleArgs,
@@ -93,35 +86,10 @@ import type {
93
86
  AssembleAppError,
94
87
  BundlerOptions,
95
88
  CanvasAppError,
96
- CanvasDrawingBufferSize,
97
89
  CreateAppOptions,
98
90
  ExecutionApp,
99
91
  } from './types';
100
92
 
101
- const DEFAULT_ASSET_CATALOG_PATH = 'pack-index.json';
102
-
103
- function defaultAssetCatalogUrl(): string {
104
- const base =
105
- typeof document !== 'undefined'
106
- ? document.baseURI
107
- : (globalThis as { location?: { href?: string } }).location?.href;
108
- return base === undefined
109
- ? DEFAULT_ASSET_CATALOG_PATH
110
- : new URL(DEFAULT_ASSET_CATALOG_PATH, base).href;
111
- }
112
-
113
- function assembleAppAssetRuntime(
114
- catalog: CatalogSource | undefined,
115
- contributions: readonly AssetDecoderContributionRef[] | undefined,
116
- ): Result<AssetRuntimeAssembly, AssetRuntimeAssemblyError> {
117
- const registry = createAssetRegistry({
118
- catalog: catalog ?? createCatalogSource({ url: defaultAssetCatalogUrl() }),
119
- });
120
- const assembled = assembleAssetRuntime(registry, contributions ?? []);
121
- if (!assembled.ok) registry.dispose();
122
- return assembled;
123
- }
124
-
125
93
  function makeAppError<C extends AppErrorCode>(
126
94
  code: C,
127
95
  expected: string,
@@ -131,23 +99,7 @@ function makeAppError<C extends AppErrorCode>(
131
99
  return new AppError({ code, expected, hint, detail }) as AppError;
132
100
  }
133
101
 
134
- function normalizeExecutionBootstrapUrl(bootstrap: string | URL): Result<string, AppError> {
135
- const moduleUrl = typeof bootstrap === 'string' ? bootstrap : bootstrap.href;
136
- try {
137
- return ok(new URL(bootstrap, globalThis.location?.href).href);
138
- } catch (cause) {
139
- return err(
140
- makeAppError(
141
- 'app-execution-bootstrap-failed',
142
- APP_EXPECTED['app-execution-bootstrap-failed'],
143
- APP_ERROR_HINTS['app-execution-bootstrap-failed'],
144
- { phase: 'prepare', moduleUrl, cause },
145
- ),
146
- );
147
- }
148
- }
149
-
150
- function canvasAspectPlugin(canvas: HTMLCanvasElement, maxCanvasPixelRatio?: number): Plugin {
102
+ function canvasAspectPlugin(canvas: HTMLCanvasElement): Plugin {
151
103
  return {
152
104
  name: 'canvas-aspect',
153
105
  inject: ['world'],
@@ -157,7 +109,7 @@ function canvasAspectPlugin(canvas: HTMLCanvasElement, maxCanvasPixelRatio?: num
157
109
  name: 'app-sync-camera-aspect',
158
110
  queries: [],
159
111
  fn: () => {
160
- syncCanvasDrawingBuffer(canvas, maxCanvasPixelRatio);
112
+ syncCanvasDrawingBuffer(canvas);
161
113
  syncCameraAspect(ctx.world, canvas.width, canvas.height);
162
114
  },
163
115
  };
@@ -210,7 +162,8 @@ export function createApp(
210
162
  ): Promise<Result<App, CanvasAppError>>;
211
163
 
212
164
  /**
213
- * createApp({ renderer, world, input?, schedule?, ... }) -- assemble-form
165
+ * Create the browser host for the single RenderScene -> FrameReceipt path.
166
+ * `createApp({ renderer, world, input?, schedule?, ... })` is the assemble form
214
167
  * SSOT (per plan-strategy D-5). Host already owns renderer / world; the
215
168
  * returned App holds them by reference equality (per AC-02).
216
169
  *
@@ -251,20 +204,6 @@ async function createAppFromCanvas(
251
204
  // document.createElement('canvas') without appendChild. Returning
252
205
  // Result.err here short-circuits before createRenderer fires off any
253
206
  // async adapter / device / shader work that would only fail later.
254
- const maxCanvasPixelRatio = opts?.maxCanvasPixelRatio;
255
- if (
256
- maxCanvasPixelRatio !== undefined &&
257
- (!Number.isFinite(maxCanvasPixelRatio) || maxCanvasPixelRatio <= 0)
258
- ) {
259
- return err(
260
- makeAppError(
261
- 'app-invalid-canvas-pixel-ratio',
262
- APP_EXPECTED['app-invalid-canvas-pixel-ratio'],
263
- APP_ERROR_HINTS['app-invalid-canvas-pixel-ratio'],
264
- { value: maxCanvasPixelRatio },
265
- ),
266
- );
267
- }
268
207
  if (!canvas.isConnected) {
269
208
  return err(
270
209
  makeAppError(
@@ -276,31 +215,23 @@ async function createAppFromCanvas(
276
215
  );
277
216
  }
278
217
 
279
- const executionOptions = opts?.execution;
280
- const normalizedExecutionBootstrap =
281
- executionOptions === undefined
282
- ? undefined
283
- : normalizeExecutionBootstrapUrl(executionOptions.bootstrap);
284
- if (normalizedExecutionBootstrap?.ok === false) {
285
- return err(normalizedExecutionBootstrap.error);
286
- }
287
-
288
218
  // The DOM canvas starts with a 300x150 drawing buffer even when CSS lays it
289
219
  // out at a different size. Set the physical buffer before the renderer
290
220
  // configures its swap chain; the same helper runs before each frame so a CSS
291
221
  // resize remains visible to both rendering and camera policy.
292
- syncCanvasDrawingBuffer(canvas, maxCanvasPixelRatio);
222
+ syncCanvasDrawingBuffer(canvas);
293
223
 
294
- if (opts?.execution !== undefined && normalizedExecutionBootstrap?.ok === true) {
224
+ let executionBootstrapUrl: string | undefined;
225
+ if (opts?.execution !== undefined) {
226
+ const normalizedBootstrap = normalizeExecutionBootstrapUrl(opts.execution.bootstrap);
227
+ if (!normalizedBootstrap.ok) return err(normalizedBootstrap.error);
228
+ executionBootstrapUrl = normalizedBootstrap.value;
295
229
  const realmBoundOption = [
296
230
  ['features', opts.features],
297
231
  ['plugins', opts.plugins],
298
232
  ['rhi', opts.rhi],
299
- ['rawDeviceForContextConfigure', opts.rawDeviceForContextConfigure],
233
+ ['rhiInstrumentation', opts.rhiInstrumentation],
300
234
  ['drawSource', opts.drawSource],
301
- ['membershipTiming', opts.membershipTiming],
302
- ['assetCatalog', opts.assetCatalog],
303
- ['assetDecoders', opts.assetDecoders],
304
235
  ['bundler.importTransport', bundler?.importTransport],
305
236
  ].find(([, value]) => value !== undefined)?.[0];
306
237
  if (realmBoundOption !== undefined) {
@@ -311,7 +242,7 @@ async function createAppFromCanvas(
311
242
  APP_ERROR_HINTS['app-execution-bootstrap-failed'],
312
243
  {
313
244
  phase: 'prepare',
314
- moduleUrl: normalizedExecutionBootstrap.value,
245
+ moduleUrl: executionBootstrapUrl,
315
246
  cause: new TypeError(
316
247
  `${realmBoundOption} must be constructed by the execution bootstrap module`,
317
248
  ),
@@ -328,11 +259,10 @@ async function createAppFromCanvas(
328
259
  }
329
260
  | undefined;
330
261
  let preparedExecutionBootstrap: PreparedExecutionBootstrap | undefined;
331
- if (opts?.execution !== undefined && normalizedExecutionBootstrap?.ok === true) {
332
- const executionOptions = opts.execution;
262
+ if (opts?.execution !== undefined) {
333
263
  const capabilities = await probeExecutionCapabilities(canvas);
334
264
  const selected = selectExecutionTier({
335
- requestedTier: executionOptions.tier ?? 'auto',
265
+ requestedTier: opts.execution.tier ?? 'auto',
336
266
  capabilities,
337
267
  sharedEvidencePassed: true,
338
268
  });
@@ -342,15 +272,18 @@ async function createAppFromCanvas(
342
272
  return createWorkerExecutionApp({
343
273
  canvas,
344
274
  appOptions: opts,
345
- syncCanvas: () => measureCanvasDrawingBuffer(canvas, maxCanvasPixelRatio),
275
+ syncCanvas: () => measureCanvasDrawingBuffer(canvas),
346
276
  ...(bundler !== undefined ? { bundler } : {}),
347
277
  capabilities,
348
278
  selection: selected.value,
349
279
  });
350
280
  }
281
+ if (executionBootstrapUrl === undefined) {
282
+ throw new Error('execution bootstrap URL was not normalized');
283
+ }
351
284
  const prepared = await prepareBootstrapEntry(
352
- normalizedExecutionBootstrap.value,
353
- executionOptions.bootstrapData,
285
+ executionBootstrapUrl,
286
+ opts.execution.bootstrapData,
354
287
  );
355
288
  if (!prepared.ok) return err(prepared.error);
356
289
  preparedExecutionBootstrap = prepared.value;
@@ -367,40 +300,34 @@ async function createAppFromCanvas(
367
300
  // forwarded to keep the contract honest (AI users walk the union
368
301
  // discriminant rather than parse error.message strings).
369
302
  // feat-20260608 / M2 / D-3: CreateAppOptions stops `extends RendererOptions`,
370
- // so the two RHI escape hatches (rhi / rawDeviceForContextConfigure) are
303
+ // so the RHI escape hatch (rhi) is
371
304
  // forwarded explicitly. Build a RendererOptions object out of just those
372
305
  // fields when present; an empty {} keeps createRenderer on its default path.
373
306
  const rendererOpts: import('@forgeax/engine-render').RendererOptions = {};
374
307
  if (opts?.rhi !== undefined) {
375
308
  Object.assign(rendererOpts, { rhi: opts.rhi });
376
309
  }
377
- if (opts?.rhiInstrumentation !== undefined) {
378
- Object.assign(rendererOpts, { rhiInstrumentation: opts.rhiInstrumentation });
379
- }
380
- if (opts?.rawDeviceForContextConfigure !== undefined) {
381
- Object.assign(rendererOpts, {
382
- rawDeviceForContextConfigure: opts.rawDeviceForContextConfigure,
383
- });
384
- }
385
310
  if (opts?.profiler !== undefined) {
386
311
  Object.assign(rendererOpts, { profiler: opts.profiler });
387
312
  }
388
- if (opts?.membershipTiming !== undefined) {
389
- Object.assign(rendererOpts, { membershipTiming: opts.membershipTiming });
390
- }
391
313
  const rendererFeatures = preparedExecutionBootstrap?.features ?? opts?.features;
392
314
  if (rendererFeatures !== undefined) {
393
315
  Object.assign(rendererOpts, { features: rendererFeatures });
394
316
  }
317
+ if (opts?.standardProfile !== undefined) {
318
+ Object.assign(rendererOpts, { standardProfile: opts.standardProfile });
319
+ }
320
+ if (opts?.rhiInstrumentation !== undefined) {
321
+ Object.assign(rendererOpts, { rhiInstrumentation: opts.rhiInstrumentation });
322
+ }
395
323
 
396
- // Optional RHI instrumentation is assembled once and forwarded through the
397
- // typed Runtime -> Render seam. App keeps only the capture capability.
324
+ // FORGEAX_ENGINE_RHI_DEBUG=1 attaches the recorder at the Runtime backend
325
+ // seam. Render remains the owner of device/surface lifecycle; App only owns
326
+ // the optional capture capability and its frame transaction.
398
327
  let rhiAttachment: RecorderAttachment | undefined;
399
328
  let rhiCapture: RhiCapture | undefined;
400
329
  let rhiDebugGlobal:
401
- | {
402
- captureFrame(options?: CaptureFrameOptions): ReturnType<RhiCapture['captureFrame']>;
403
- }
330
+ | { captureFrame(options?: CaptureFrameOptions): ReturnType<RhiCapture['captureFrame']> }
404
331
  | undefined;
405
332
  const cleanupRhiDebugHost = (): void => {
406
333
  const host = globalThis as { __forgeax?: typeof rhiDebugGlobal };
@@ -445,65 +372,55 @@ async function createAppFromCanvas(
445
372
  : undefined;
446
373
  const hasWebGPU = nav !== undefined && 'gpu' in nav && nav.gpu !== undefined;
447
374
  if (
448
- opts?.rhi === undefined &&
449
375
  (browserBuildRhiDebugFlag === undefined || browserBuildRhiDebugFlag === '1') &&
450
376
  rhiDebugFlag === '1'
451
377
  ) {
452
- const backend = hasWebGPU
378
+ const realBackend = (hasWebGPU
453
379
  ? await import('@forgeax/engine-rhi-webgpu')
454
- : await import('@forgeax/engine-rhi-wgpu');
455
- if (!hasWebGPU && 'ensureReady' in backend) await backend.ensureReady();
456
- const pack = loadRhiPack(backend);
457
- const debugModule = await import(/* @vite-ignore */ '@forgeax/engine-rhi-debug');
380
+ : await import('@forgeax/engine-rhi-wgpu')) as unknown as Record<string, unknown>;
381
+ if (!hasWebGPU && 'ensureReady' in realBackend) {
382
+ await (realBackend.ensureReady as () => Promise<unknown>)();
383
+ }
384
+ const pack = loadRhiPack(realBackend);
458
385
  if (pack.createShaderModule === undefined) {
459
- throw new Error('RHI debug capture requires the backend shader-module entry');
386
+ throw new Error('RHI-debug requires a backend createShaderModule capability');
460
387
  }
461
- const createShaderModule: CreateShaderModuleFn = pack.createShaderModule;
462
- const attached = debugModule.attachRecorder({
388
+ const attached = attachRecorder({
463
389
  rhi: pack.rhi,
464
- createShaderModule,
390
+ createShaderModule: pack.createShaderModule,
465
391
  });
466
392
  if (!attached.ok) throw new Error(attached.error.hint);
467
- const attachment = attached.value;
468
- rhiAttachment = attachment;
469
- const capture = createRhiCapture(attachment);
470
- rhiCapture = capture;
471
-
393
+ rhiAttachment = attached.value;
394
+ rhiCapture = createRhiCapture(attached.value);
395
+ const capture = rhiCapture;
396
+ rhiDebugGlobal = { captureFrame: (options) => capture.captureFrame(options) };
472
397
  Object.assign(rendererOpts, {
473
- rhi: attachment.backend.rhi,
474
- rhiInstrumentation: createRhiInstrumentation(attachment),
398
+ rhi: attached.value.backend.rhi,
399
+ rhiInstrumentation: createRhiInstrumentation(attached.value),
475
400
  });
476
-
477
- rhiDebugGlobal = {
478
- captureFrame: (options) => capture.captureFrame(options),
479
- };
480
401
  (globalThis as { __forgeax?: typeof rhiDebugGlobal }).__forgeax = rhiDebugGlobal;
481
402
  }
482
403
 
483
- const assetAssemblyResult = assembleAppAssetRuntime(opts?.assetCatalog, opts?.assetDecoders);
484
- if (!assetAssemblyResult.ok) {
485
- cleanupRhiDebugHost();
486
- return err(assetAssemblyResult.error);
487
- }
488
- const assetAssembly = assetAssemblyResult.value;
489
404
  let renderer: Renderer;
405
+ let rendererDebugDrawHost: RendererHostAssembly['debugDrawHost'];
406
+ let rendererFeatureHost: RenderFeatureHost;
407
+ let assets: AssetRegistry;
490
408
  try {
491
- renderer = await createRenderer(canvas, rendererOpts, {
492
- ...bundler,
493
- assetResolver: getAssetRegistryResolver(assetAssembly.registry),
494
- });
409
+ const constructed = await constructRuntimeRendererHost(canvas, rendererOpts, bundler);
410
+ if (!constructed.ok) throw constructed.error;
411
+ renderer = constructed.value.renderer;
412
+ rendererDebugDrawHost = constructed.value.debugDrawHost;
413
+ rendererFeatureHost = createRenderFeatureHost(constructed.value.featureHost);
414
+ assets = constructed.value.assets;
495
415
  } catch (e: unknown) {
496
416
  cleanupRhiDebugHost();
497
- assetAssembly.dispose();
498
- assetAssembly.registry.dispose();
499
- if (e instanceof EngineEnvironmentError) {
500
- return err(e);
501
- }
502
- // Unknown throw shapes (RhiError surfaced as throw, raw Error, ...).
503
- // Re-raise to preserve fail-fast: the contract pins
504
- // EngineEnvironmentError as the only construction-time failure
505
- // shape; anything else is an engine bug, not an app-shell concern.
506
- throw e;
417
+ if (e instanceof EngineEnvironmentError) return err(e);
418
+ const detail = e instanceof Error ? e : new Error(String(e));
419
+ return err(
420
+ new EngineEnvironmentError('renderer construction failed', {
421
+ webgpuError: detail,
422
+ }),
423
+ );
507
424
  }
508
425
 
509
426
  // Step 2.4 decision: resolve whether the remote eval server should start
@@ -516,7 +433,6 @@ async function createAppFromCanvas(
516
433
  ? (import.meta as { env?: { DEV?: boolean } }).env?.DEV
517
434
  : undefined,
518
435
  (globalThis as { process?: { env?: { FORGEAX_ENGINE_REMOTE_SERVE?: string } } }).process?.env,
519
- typeof window !== 'undefined',
520
436
  );
521
437
 
522
438
  let debugDraw: DebugDraw | undefined;
@@ -564,8 +480,10 @@ async function createAppFromCanvas(
564
480
  world,
565
481
  mainEngineProfile({
566
482
  renderer,
567
- assetAssembly,
568
- animationPayloads: createAnimationPayloadLookup(renderer.assets),
483
+ rendererDebugDrawHost,
484
+ rendererFeatureHost,
485
+ assets,
486
+ animationPayloads: createAnimationPayloadLookup(assets),
569
487
  ...(inputBackend === undefined ? {} : { input: inputBackend }),
570
488
  ...(inputHandle === undefined ? {} : { inputDispose: inputHandle.cleanup }),
571
489
  ...(opts?.inputMap === undefined ? {} : { inputMap: opts.inputMap }),
@@ -575,14 +493,12 @@ async function createAppFromCanvas(
575
493
  extensions: [
576
494
  rhiDebugHostPlugin(cleanupRhiDebugHost),
577
495
  ...userPlugins,
578
- canvasAspectPlugin(canvas, maxCanvasPixelRatio),
496
+ canvasAspectPlugin(canvas),
579
497
  ],
580
498
  }),
581
499
  );
582
500
  } catch (cause) {
583
501
  cleanupRhiDebugHost();
584
- assetAssembly.dispose();
585
- assetAssembly.registry.dispose();
586
502
  return err(
587
503
  makeAppError(
588
504
  'app-plugin-activation-failed',
@@ -639,23 +555,17 @@ async function createAppFromCanvas(
639
555
  // of static dep on @forgeax/engine-remote. Component reflection crosses
640
556
  // this boundary as JSON-safe host data; app does not own any component.
641
557
  const remoteHandle = shouldStartRemote
642
- ? await startRemoteServer(
643
- world,
644
- renderer,
645
- assetAssembly.registry,
646
- rhiCapture,
647
- opts?.profiler,
648
- executionControl,
649
- )
558
+ ? await startRemoteServer(world, renderer, assets, rhiCapture, opts?.profiler, executionControl)
650
559
  : undefined;
651
560
  if (remoteHandle !== undefined) await pluginContext.plugin(remoteServerPlugin(remoteHandle));
652
561
 
653
562
  const buildArgs: BuildAppArgs = {
654
563
  renderer,
655
- assets: assetAssembly.registry,
564
+ assets,
656
565
  world,
657
566
  pluginContext,
658
567
  executionControl,
568
+ onFrameSubmitted: (event) => publishBrowserFrameSubmitted(canvas, event),
659
569
  ...(inputBackend !== undefined ? { inputBackend } : {}),
660
570
  ...(inputHandle === undefined
661
571
  ? {}
@@ -681,10 +591,7 @@ async function createAppFromCanvas(
681
591
  Object.assign(buildArgs, { profiler: opts.profiler });
682
592
  }
683
593
  if (rhiCapture !== undefined) {
684
- Object.assign(buildArgs, {
685
- rhiCapture,
686
- ...(rhiAttachment === undefined ? {} : { rhiAttachment }),
687
- });
594
+ Object.assign(buildArgs, { rhiCapture });
688
595
  }
689
596
  if (debugDraw !== undefined) {
690
597
  Object.assign(buildArgs, { debugDraw });
@@ -695,6 +602,7 @@ async function createAppFromCanvas(
695
602
 
696
603
  // The aspect-sync sidecar belongs to the canvas path because it owns the DOM
697
604
  // canvas. It runs as an Update system so it shares World scheduling semantics.
605
+ resetBrowserFrameSubmitted(canvas);
698
606
  const built = await buildApp(buildArgs);
699
607
  if (!built.ok) {
700
608
  await pluginContext.fiber.dispose();
@@ -727,17 +635,17 @@ async function createAppFromCanvas(
727
635
  ?.VITE_FORGEAX_ENGINE_BRIDGE_PORT ?? '5733';
728
636
  await pluginContext.plugin({
729
637
  name: 'browser-remote-bridge',
730
- inject: ['world', 'renderer', 'assetRegistry'],
638
+ inject: ['world', 'renderer', 'assets'],
731
639
  async apply(ctx) {
732
- if (ctx.renderer === undefined || ctx.assetRegistry === undefined) {
733
- throw new Error('browser remote bridge requires renderer and assetRegistry services');
640
+ if (ctx.renderer === undefined || ctx.assets === undefined) {
641
+ throw new Error('browser remote bridge requires renderer and assets services');
734
642
  }
735
643
  try {
736
644
  const bridge = await import('./internal/browser-remote-bridge');
737
645
  const teardown = await bridge.installBrowserRemoteBridge({
738
646
  world: ctx.world,
739
647
  renderer: ctx.renderer,
740
- assets: ctx.assetRegistry,
648
+ assets: ctx.assets,
741
649
  runtimeModule: engineRuntimeModule,
742
650
  ...(rhiCapture !== undefined ? { rhiCapture } : {}),
743
651
  ...(opts?.profiler !== undefined ? { profiler: opts.profiler } : {}),
@@ -764,47 +672,14 @@ export function syncCanvasDrawingBuffer(
764
672
  canvas: Pick<HTMLCanvasElement, 'clientWidth' | 'clientHeight' | 'width' | 'height'> & {
765
673
  readonly style?: Pick<CSSStyleDeclaration, 'width' | 'height'>;
766
674
  },
767
- maxCanvasPixelRatio?: number,
768
- ): CanvasDrawingBufferSize {
769
- const size = resolveCanvasDrawingBufferSize(canvas, maxCanvasPixelRatio);
770
- if (size === undefined) return { width: canvas.width, height: canvas.height };
771
- if (canvas.width !== size.width) canvas.width = size.width;
772
- if (canvas.height !== size.height) canvas.height = size.height;
773
- return size;
774
- }
775
-
776
- /**
777
- * Measure the next effective drawing buffer without writing the DOM canvas.
778
- * This is the Worker host path because transferControlToOffscreen makes the
779
- * HTML canvas dimensions immutable; the Worker owns the transferred surface.
780
- */
781
- export function measureCanvasDrawingBuffer(
782
- canvas: Pick<HTMLCanvasElement, 'clientWidth' | 'clientHeight' | 'width' | 'height'> & {
783
- readonly style?: Pick<CSSStyleDeclaration, 'width' | 'height'>;
784
- },
785
- maxCanvasPixelRatio?: number,
786
- ): CanvasDrawingBufferSize {
787
- return (
788
- resolveCanvasDrawingBufferSize(canvas, maxCanvasPixelRatio) ?? {
789
- width: canvas.width,
790
- height: canvas.height,
791
- }
792
- );
793
- }
794
-
795
- function resolveCanvasDrawingBufferSize(
796
- canvas: Pick<HTMLCanvasElement, 'clientWidth' | 'clientHeight' | 'width' | 'height'> & {
797
- readonly style?: Pick<CSSStyleDeclaration, 'width' | 'height'>;
798
- },
799
- maxCanvasPixelRatio?: number,
800
- ): CanvasDrawingBufferSize | undefined {
675
+ ): void {
801
676
  if (
802
677
  !Number.isFinite(canvas.clientWidth) ||
803
678
  !Number.isFinite(canvas.clientHeight) ||
804
679
  canvas.clientWidth <= 0 ||
805
680
  canvas.clientHeight <= 0
806
681
  )
807
- return undefined;
682
+ return;
808
683
  // An intrinsic canvas with no CSS dimensions already has an explicit
809
684
  // drawing-buffer contract. `clientWidth` mirrors `width` for this shape;
810
685
  // multiplying it by DPR would change the caller's requested pixel size and
@@ -818,15 +693,9 @@ function resolveCanvasDrawingBufferSize(
818
693
  canvas.clientWidth === canvas.width &&
819
694
  canvas.clientHeight === canvas.height
820
695
  ) {
821
- return { width: canvas.width, height: canvas.height };
696
+ return;
822
697
  }
823
- const devicePixelRatio = Math.max(1, globalThis.devicePixelRatio || 1);
824
- const dpr =
825
- maxCanvasPixelRatio === undefined ||
826
- !Number.isFinite(maxCanvasPixelRatio) ||
827
- maxCanvasPixelRatio <= 0
828
- ? devicePixelRatio
829
- : Math.min(devicePixelRatio, maxCanvasPixelRatio);
698
+ const dpr = Math.max(1, globalThis.devicePixelRatio || 1);
830
699
  const previous = syncedCanvasSizes.get(canvas);
831
700
  const drawingBufferIsTheLayoutMeasurement =
832
701
  previous !== undefined &&
@@ -838,13 +707,60 @@ function resolveCanvasDrawingBufferSize(
838
707
  const cssHeight = drawingBufferIsTheLayoutMeasurement ? previous.cssHeight : canvas.clientHeight;
839
708
  const width = Math.max(1, Math.round(cssWidth * dpr));
840
709
  const height = Math.max(1, Math.round(cssHeight * dpr));
710
+ if (canvas.width !== width) canvas.width = width;
711
+ if (canvas.height !== height) canvas.height = height;
841
712
  syncedCanvasSizes.set(canvas, {
842
713
  cssWidth,
843
714
  cssHeight,
844
715
  drawingWidth: width,
845
716
  drawingHeight: height,
846
717
  });
847
- return { width, height };
718
+ }
719
+
720
+ /** Measure a CSS-sized canvas without mutating its drawing buffer. */
721
+ export function measureCanvasDrawingBuffer(
722
+ canvas: Pick<HTMLCanvasElement, 'clientWidth' | 'clientHeight' | 'width' | 'height'> & {
723
+ readonly style?: Pick<CSSStyleDeclaration, 'width' | 'height'>;
724
+ },
725
+ maxCanvasPixelRatio?: number,
726
+ ): { readonly width: number; readonly height: number } {
727
+ if (
728
+ !Number.isFinite(canvas.clientWidth) ||
729
+ !Number.isFinite(canvas.clientHeight) ||
730
+ canvas.clientWidth <= 0 ||
731
+ canvas.clientHeight <= 0
732
+ ) {
733
+ return { width: canvas.width, height: canvas.height };
734
+ }
735
+ const hasExplicitCssSize =
736
+ (canvas.style?.width ?? '') !== '' || (canvas.style?.height ?? '') !== '';
737
+ if (
738
+ !hasExplicitCssSize &&
739
+ canvas.clientWidth === canvas.width &&
740
+ canvas.clientHeight === canvas.height
741
+ ) {
742
+ return { width: canvas.width, height: canvas.height };
743
+ }
744
+ const devicePixelRatio = Math.max(1, globalThis.devicePixelRatio || 1);
745
+ const dpr =
746
+ maxCanvasPixelRatio === undefined ||
747
+ !Number.isFinite(maxCanvasPixelRatio) ||
748
+ maxCanvasPixelRatio <= 0
749
+ ? devicePixelRatio
750
+ : Math.min(devicePixelRatio, maxCanvasPixelRatio);
751
+ const previous = syncedCanvasSizes.get(canvas);
752
+ const drawingBufferIsTheLayoutMeasurement =
753
+ previous !== undefined &&
754
+ canvas.width === previous.drawingWidth &&
755
+ canvas.height === previous.drawingHeight &&
756
+ canvas.clientWidth === previous.drawingWidth &&
757
+ canvas.clientHeight === previous.drawingHeight;
758
+ const cssWidth = drawingBufferIsTheLayoutMeasurement ? previous.cssWidth : canvas.clientWidth;
759
+ const cssHeight = drawingBufferIsTheLayoutMeasurement ? previous.cssHeight : canvas.clientHeight;
760
+ return {
761
+ width: Math.max(1, Math.round(cssWidth * dpr)),
762
+ height: Math.max(1, Math.round(cssHeight * dpr)),
763
+ };
848
764
  }
849
765
 
850
766
  const syncedCanvasSizes = new WeakMap<
@@ -857,10 +773,49 @@ const syncedCanvasSizes = new WeakMap<
857
773
  }
858
774
  >();
859
775
 
776
+ /**
777
+ * Per-frame aspect-sync body for the createApp(canvas) path (feat-20260617
778
+ * M3 / w13). Walks every Camera entity and, for perspective cameras with
779
+ * `autoAspect === true`, writes `canvasW / canvasH` into `Camera.aspect`.
780
+ *
781
+ * Read discipline (D-5 / research Finding 2): `autoAspect` is read through
782
+ * `world.get` (the readRow path narrows the bool column to a JS boolean).
783
+ * The query bundle is used only to enumerate the entity handles -- reading
784
+ * the bool column off the bundle would return a raw 0/1 number, so a
785
+ * `!== 0` test is always true (the
786
+ * bool-field-compared-with-not-equal-zero-always-true trap).
787
+ *
788
+ * Best-effort + side-effect-isolated:
789
+ * - canvas size 0 (detached / display:none) -> skip entirely so `aspect`
790
+ * never becomes NaN / 0.
791
+ * - orthographic cameras and `autoAspect === false` cameras are left
792
+ * untouched.
793
+ *
794
+ * @see {@link https://github.com/Ubpa/forgeax-engine/blob/main/docs/how-to/2026-06-18-host-engine-contract.md | Host-engine contract SSOT}
795
+ */
796
+ export function syncCameraAspect(world: World, canvasW: number, canvasH: number): void {
797
+ // Guard against detached / zero-sized canvases: a 0 width or height would
798
+ // write NaN (0 / 0) or 0 into aspect and corrupt the projection matrix.
799
+ if (canvasW <= 0 || canvasH <= 0) return;
800
+ const aspect = canvasW / canvasH;
801
+
802
+ const query = world.query({ with: [Camera] }).unwrap();
803
+ for (const row of query) {
804
+ const entity = row.entity;
805
+ const r = world.get(entity, Camera);
806
+ if (!r.ok) continue;
807
+ // world.get narrows the bool column to a real boolean (D-5); the
808
+ // perspective discriminator is the numeric column value.
809
+ if (r.value.autoAspect !== true) continue;
810
+ if (r.value.projection !== CAMERA_PROJECTION_PERSPECTIVE) continue;
811
+ world.set(entity, Camera, { aspect });
812
+ }
813
+ }
814
+
860
815
  async function startRemoteServer(
861
816
  world: World,
862
817
  renderer: Renderer,
863
- assets: AssetRegistry,
818
+ assets: AssetRegistry | undefined,
864
819
  rhiCapture: RhiCapture | undefined,
865
820
  profiler: import('@forgeax/engine-profiler').Profiler | undefined,
866
821
  execution: ExecutionControl,
@@ -907,22 +862,17 @@ async function startRemoteServer(
907
862
  async function createAppFromAssemble(
908
863
  args: AppAssembleArgs,
909
864
  ): Promise<Result<App, AssembleAppError>> {
910
- const assetAssemblyResult = assembleAppAssetRuntime(args.assetCatalog, args.assetDecoders);
911
- if (!assetAssemblyResult.ok) return err(assetAssemblyResult.error);
912
- const assetAssembly = assetAssemblyResult.value;
913
865
  let pluginContext: Context;
914
866
  try {
915
867
  pluginContext = await createWorldContext(
916
868
  args.world,
917
869
  assembledEngineProfile({
918
870
  renderer: args.renderer,
919
- assetAssembly,
871
+ ...(args.assets === undefined ? {} : { assets: args.assets }),
920
872
  extensions: args.plugins ?? [],
921
873
  }),
922
874
  );
923
875
  } catch (cause) {
924
- assetAssembly.dispose();
925
- assetAssembly.registry.dispose();
926
876
  return err(
927
877
  makeAppError(
928
878
  'app-plugin-activation-failed',
@@ -968,7 +918,7 @@ async function createAppFromAssemble(
968
918
  ? await startRemoteServer(
969
919
  args.world,
970
920
  args.renderer,
971
- assetAssembly.registry,
921
+ args.assets,
972
922
  undefined,
973
923
  args.profiler,
974
924
  executionControl,
@@ -978,7 +928,6 @@ async function createAppFromAssemble(
978
928
 
979
929
  const buildArgs: BuildAppArgs = {
980
930
  renderer: args.renderer,
981
- assets: assetAssembly.registry,
982
931
  world: args.world,
983
932
  pluginContext,
984
933
  executionControl,
@@ -1013,7 +962,7 @@ async function createAppFromAssemble(
1013
962
 
1014
963
  interface BuildAppArgs {
1015
964
  readonly renderer: Renderer;
1016
- readonly assets: AssetRegistry;
965
+ readonly assets?: AssetRegistry;
1017
966
  readonly world: World;
1018
967
  readonly pluginContext: Context;
1019
968
  readonly inputBackend?: InputBackend;
@@ -1027,10 +976,8 @@ interface BuildAppArgs {
1027
976
  readonly wireOnLockErrorDispatch?: (dispatch: (err: AppError) => void) => void;
1028
977
  readonly audioBackend?: AudioBackend;
1029
978
  readonly silenceUnhandledErrors?: boolean;
1030
- /** M4: host-owned RHI capture capability attached to the app surface. */
979
+ /** Optional App-owned RHI capture capability. */
1031
980
  readonly rhiCapture?: RhiCapture;
1032
- /** Internal attachment used to bind capture to this App's frame authority. */
1033
- readonly rhiAttachment?: RecorderAttachment;
1034
981
  /** feat-20260615 debug-draw M5: DebugDraw instance created by createDebugDrawOnReady. */
1035
982
  readonly debugDraw?: DebugDraw;
1036
983
  /** feat-20260629 M4 / w20: remote eval server handle from createAppFromCanvas. */
@@ -1050,6 +997,11 @@ interface BuildAppArgs {
1050
997
  | undefined;
1051
998
  readonly profiler?: import('@forgeax/engine-profiler').Profiler;
1052
999
  readonly executionControl?: import('./execution/control').LocalExecutionControl;
1000
+ /** Canvas-form projection of the Renderer frame-submitted event. */
1001
+ readonly onFrameSubmitted?: (event: {
1002
+ readonly frameId: number;
1003
+ readonly deviceGeneration: number;
1004
+ }) => void;
1053
1005
  }
1054
1006
 
1055
1007
  /**
@@ -1060,14 +1012,12 @@ interface BuildAppArgs {
1060
1012
  async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiError>> {
1061
1013
  const {
1062
1014
  renderer,
1063
- assets,
1064
1015
  world,
1065
1016
  pluginContext,
1066
1017
  inputBackend,
1067
1018
  audioBackend,
1068
1019
  silenceUnhandledErrors,
1069
1020
  rhiCapture,
1070
- rhiAttachment,
1071
1021
  debugDraw,
1072
1022
  remoteHandle,
1073
1023
  profiler,
@@ -1088,6 +1038,7 @@ async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiE
1088
1038
  const fanout = new ErrorFanoutRegistry(
1089
1039
  silenceUnhandledErrors !== undefined ? { silenceUnhandledErrors } : {},
1090
1040
  );
1041
+ let lastError: AppDispatchError | undefined;
1091
1042
  const execution =
1092
1043
  args.executionControl ??
1093
1044
  createLocalExecutionControl(
@@ -1116,6 +1067,7 @@ async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiE
1116
1067
  );
1117
1068
 
1118
1069
  function dispatch(e: AppDispatchError): void {
1070
+ lastError = e;
1119
1071
  fanout.fire(e);
1120
1072
  }
1121
1073
 
@@ -1141,8 +1093,7 @@ async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiE
1141
1093
  Object.assign(loopOpts, { profiler });
1142
1094
  }
1143
1095
  const loop = createFrameLoop(loopOpts);
1144
-
1145
- if (rhiCapture !== undefined && rhiAttachment !== undefined) {
1096
+ if (rhiCapture !== undefined) {
1146
1097
  bindRhiCaptureFrameDriver(rhiCapture, {
1147
1098
  getState: () => loop.getState(),
1148
1099
  pause: () => {
@@ -1158,9 +1109,6 @@ async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiE
1158
1109
  stepFrame: (deltaSeconds) => loop.stepFrame(deltaSeconds),
1159
1110
  });
1160
1111
  }
1161
-
1162
- let lastError: AppDispatchError | undefined;
1163
-
1164
1112
  // M4 (w13) device-lost internal subscription. R-1 timing contract:
1165
1113
  // app.start() arms the rAF handle BEFORE this listener subscribes, so
1166
1114
  // a synchronous late-attach replay of a persisted device-lost event
@@ -1177,32 +1125,13 @@ async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiE
1177
1125
  if (rendererUnsubscribe !== undefined) {
1178
1126
  return;
1179
1127
  }
1180
- rendererUnsubscribe = renderer.onError((e: RendererError) => {
1181
- // D-3: device-lost stays in RhiError 18-member union; AppError does
1182
- // NOT add 'app-device-lost'. The host onError listener receives the
1183
- // error verbatim through the fanout dispatch below.
1184
- //
1185
- // feat-20260531-skybox-env-background F-1: the renderer onError channel
1186
- // now fans out RhiError | RuntimeError (e.g. 'equirect-projection-failed').
1187
- // Device loss is recoverable at the Renderer boundary: the frame-loop
1188
- // heartbeat remains armed and freezes World/update work until the host
1189
- // explicitly calls `renderer.recover()`. Stopping the loop here would
1190
- // leave a successful recovery
1191
- // with no frame submitter.
1192
- //
1193
- // Note: we discriminate by .code rather than instanceof RhiError
1194
- // because the listener may be invoked across module boundaries
1195
- // (re-export from @forgeax/engine-runtime vs direct
1196
- // @forgeax/engine-rhi/errors import). Bundler dedup is not
1197
- // guaranteed on subpath exports, so an instanceof check is a
1198
- // false-negative trap. The union .code type still provides static
1199
- // safety on .code access.
1200
- if (e?.code === 'device-lost') {
1201
- lastError = e;
1128
+ rendererUnsubscribe = renderer.subscribe((event) => {
1129
+ if (event.kind === 'frame-submitted') {
1130
+ args.onFrameSubmitted?.(event);
1131
+ return;
1202
1132
  }
1203
- // Always fan out to host listeners (D-2 last bullet: device-lost
1204
- // error is forwarded as-is to host onError listener so the host
1205
- // can decide whether to rebuild the renderer).
1133
+ if (event.kind !== 'error') return;
1134
+ const e: RenderError = event.error;
1206
1135
  dispatch(e);
1207
1136
  });
1208
1137
  }
@@ -1216,10 +1145,10 @@ async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiE
1216
1145
 
1217
1146
  const stub: App = {
1218
1147
  renderer,
1219
- assets,
1148
+ ...(args.assets === undefined ? {} : { assets: args.assets }),
1220
1149
  world,
1221
1150
  execution,
1222
- async releaseSurfacePreserveWorld(): Promise<Result<void, RhiError>> {
1151
+ async releaseSurfacePreserveWorld(): Promise<Result<void, RhiError | RenderError>> {
1223
1152
  if (loop.getState() === 'running') {
1224
1153
  const paused = loop.pause();
1225
1154
  if (!paused.ok) {
@@ -1246,7 +1175,7 @@ async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiE
1246
1175
  }
1247
1176
  return released.ok ? ok(undefined) : err(released.error);
1248
1177
  },
1249
- async restoreSurface(): Promise<Result<void, RhiError>> {
1178
+ async restoreSurface(): Promise<Result<void, RhiError | RenderError>> {
1250
1179
  const restored = renderer.restoreSurface();
1251
1180
  if (!restored.ok) return err(restored.error);
1252
1181
  if (resumeAfterSurfaceRestore) {
@@ -1276,7 +1205,7 @@ async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiE
1276
1205
  },
1277
1206
  start(): Result<void, AppError> {
1278
1207
  // R-1: arm the rAF handle FIRST (loop.start schedules raf(tick))
1279
- // and only THEN subscribe to renderer.onError. If the renderer
1208
+ // and only THEN subscribe to renderer events. If the renderer
1280
1209
  // late-attach replays a persisted device-lost event during the
1281
1210
  // subscribe call, the loop is already armed and can retain the error.
1282
1211
  const r = loop.start();
@@ -1321,7 +1250,7 @@ async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiE
1321
1250
  loop.setDrawSource(drawSource);
1322
1251
  },
1323
1252
  /**
1324
- * Most recent device-loss error retained for host self-inspection.
1253
+ * Most recent dispatched error retained for host self-inspection.
1325
1254
  */
1326
1255
  get lastError(): AppDispatchError | undefined {
1327
1256
  return lastError;
@@ -1331,21 +1260,5 @@ async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiE
1331
1260
  ...(remoteHandle !== undefined ? { remote: remoteHandle } : {}),
1332
1261
  };
1333
1262
 
1334
- // Readiness barrier (charter Fail Fast). createRenderer resolves before
1335
- // its `ready` Promise (manifest -> pipeline -> asset upload three-step
1336
- // chain) settles, so a host that calls app.start() immediately would arm
1337
- // the rAF loop while renderer.draw(world) still returns 'rhi-not-available'
1338
- // every frame -- a startup race that surfaces as intermittent console.error
1339
- // spam on cold loads. Awaiting ready here makes "App ready" mean "renderer
1340
- // ready": start() never observes a pre-ready frame, and a genuine pipeline
1341
- // build failure fail-fasts as Result.err(rhiError) (caught by the canonical
1342
- // `if (!app.ok) reportError(app.error)` takeoff) instead of per-frame noise.
1343
- // The audio tick system is registered by audioPlugin before buildApp as the 'audio-tick'
1344
- // world system, so there is no buildApp-side Update system anymore.
1345
- const readyResult = await renderer.ready;
1346
- if (!readyResult.ok) {
1347
- return err(readyResult.error);
1348
- }
1349
-
1350
1263
  return ok(stub);
1351
1264
  }