@forgeax/engine-app 0.1.3 → 0.1.6

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 (145) 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/create-app.d.ts +31 -10
  10. package/dist/create-app.d.ts.map +1 -1
  11. package/dist/engine-worker-runtime.mjs +72 -129
  12. package/dist/engine-worker-runtime.mjs.map +1 -1
  13. package/dist/errors.d.ts +3 -12
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/execution/attached-world-swap.d.ts +1 -12
  16. package/dist/execution/attached-world-swap.d.ts.map +1 -1
  17. package/dist/execution/bootstrap-url.d.ts +5 -0
  18. package/dist/execution/bootstrap-url.d.ts.map +1 -0
  19. package/dist/execution/control.d.ts +0 -2
  20. package/dist/execution/control.d.ts.map +1 -1
  21. package/dist/execution/engine-worker.d.ts +1 -0
  22. package/dist/execution/engine-worker.d.ts.map +1 -1
  23. package/dist/execution/host-controller.d.ts.map +1 -1
  24. package/dist/execution/index.d.ts +1 -1
  25. package/dist/execution/index.d.ts.map +1 -1
  26. package/dist/execution/protocol.d.ts +4 -1
  27. package/dist/execution/protocol.d.ts.map +1 -1
  28. package/dist/execution/types.d.ts +17 -1
  29. package/dist/execution/types.d.ts.map +1 -1
  30. package/dist/game-context.d.ts +1 -1
  31. package/dist/game-context.d.ts.map +1 -1
  32. package/dist/index.d.ts +4 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.mjs +332 -366
  35. package/dist/index.mjs.map +1 -1
  36. package/dist/internal/assembled-engine-profile.d.ts.map +1 -1
  37. package/dist/internal/assets-world-plugin.d.ts +3 -17
  38. package/dist/internal/assets-world-plugin.d.ts.map +1 -1
  39. package/dist/internal/browser-remote-bridge.d.ts +1 -2
  40. package/dist/internal/browser-remote-bridge.d.ts.map +1 -1
  41. package/dist/internal/browser-rhi-debug-runtime.d.ts +1 -1
  42. package/dist/internal/browser-rhi-debug-runtime.d.ts.map +1 -1
  43. package/dist/internal/debug-draw.d.ts +9 -0
  44. package/dist/internal/debug-draw.d.ts.map +1 -0
  45. package/dist/internal/engine-profile-common.d.ts +2 -2
  46. package/dist/internal/engine-profile-common.d.ts.map +1 -1
  47. package/dist/internal/frame-loop.d.ts +7 -4
  48. package/dist/internal/frame-loop.d.ts.map +1 -1
  49. package/dist/internal/main-engine-profile.d.ts +4 -0
  50. package/dist/internal/main-engine-profile.d.ts.map +1 -1
  51. package/dist/internal/remote-serve-flag.d.ts +3 -7
  52. package/dist/internal/remote-serve-flag.d.ts.map +1 -1
  53. package/dist/internal/rhi-capture.d.ts +1 -1
  54. package/dist/internal/rhi-capture.d.ts.map +1 -1
  55. package/dist/internal/worker-engine-profile.d.ts +2 -0
  56. package/dist/internal/worker-engine-profile.d.ts.map +1 -1
  57. package/dist/renderer-plugin.d.ts +25 -0
  58. package/dist/renderer-plugin.d.ts.map +1 -0
  59. package/dist/tool-preview/bootstrap.d.ts.map +1 -1
  60. package/dist/types.d.ts +21 -61
  61. package/dist/types.d.ts.map +1 -1
  62. package/package.json +25 -26
  63. package/src/__tests__/callback-inference.test-d.ts +7 -3
  64. package/src/__tests__/consumer-migration.unit.test.ts +13 -0
  65. package/src/__tests__/create-app-error-retention.integration.test.ts +125 -0
  66. package/src/__tests__/create-app-features.test.ts +21 -10
  67. package/src/__tests__/create-app-listener-sync.test.ts +23 -19
  68. package/src/__tests__/create-app-physics-plugin-failure.integration.test.ts +142 -0
  69. package/src/__tests__/create-app-stop.test.ts +4 -12
  70. package/src/__tests__/create-app.test-d.ts +12 -11
  71. package/src/__tests__/create-app.test.ts +1 -60
  72. package/src/__tests__/create-renderer-error.integration.test.ts +3 -2
  73. package/src/__tests__/create-renderer-lifecycle.integration.test.ts +10 -5
  74. package/src/__tests__/draw-source-injected-world-update-visibility.test.ts +7 -13
  75. package/src/__tests__/draw-source-single-world-regression.test.ts +23 -24
  76. package/src/__tests__/engine-worker-bootstrap-channel.unit.test.ts +8 -0
  77. package/src/__tests__/engine-worker-world-swap.unit.test.ts +15 -22
  78. package/src/__tests__/errors-pointer-lock-failed.test.ts +4 -7
  79. package/src/__tests__/execution-bootstrap-isolation.unit.test.ts +5 -6
  80. package/src/__tests__/execution-frame-order.unit.test.ts +2 -2
  81. package/src/__tests__/execution-frame-protocol.unit.test.ts +3 -4
  82. package/src/__tests__/execution-public-api.test-d.ts +4 -0
  83. package/src/__tests__/frame-loop-time-elapsed.test.ts +1 -1
  84. package/src/__tests__/frame-loop-time.unit.test.ts +1 -1
  85. package/src/__tests__/frame-loop-world-array.test.ts +82 -73
  86. package/src/__tests__/plugin-profiles.unit.test.ts +3 -3
  87. package/src/__tests__/profiler-default-off.test.ts +1 -1
  88. package/src/__tests__/profiler-frame-token.test.ts +17 -30
  89. package/src/__tests__/profiler-phase-integration.test.ts +15 -27
  90. package/src/__tests__/surface-handoff.test.ts +1 -4
  91. package/src/__tests__/visibility-remote.integration.test.ts +2 -5
  92. package/src/__tests__/worker-execution-stop.test.ts +64 -7
  93. package/src/animation-asset-lookup.ts +2 -2
  94. package/src/create-app.ts +197 -296
  95. package/src/errors.ts +27 -39
  96. package/src/execution/attached-world-swap.ts +4 -38
  97. package/src/execution/bootstrap-url.ts +25 -0
  98. package/src/execution/control.ts +3 -7
  99. package/src/execution/engine-worker-runtime.ts +23 -40
  100. package/src/execution/engine-worker.ts +2 -0
  101. package/src/execution/host-controller.ts +8 -2
  102. package/src/execution/index.ts +1 -0
  103. package/src/execution/protocol.ts +4 -1
  104. package/src/execution/types.ts +18 -1
  105. package/src/game-context.ts +1 -1
  106. package/src/index.ts +11 -4
  107. package/src/internal/assembled-engine-profile.ts +3 -11
  108. package/src/internal/assets-world-plugin.ts +7 -53
  109. package/src/internal/browser-remote-bridge.ts +12 -10
  110. package/src/internal/browser-rhi-debug-runtime.ts +1 -1
  111. package/src/internal/debug-draw.ts +38 -0
  112. package/src/internal/engine-profile-common.ts +2 -3
  113. package/src/internal/frame-loop.ts +77 -63
  114. package/src/internal/main-engine-profile.ts +21 -17
  115. package/src/internal/remote-serve-flag.ts +5 -14
  116. package/src/internal/rhi-capture.ts +1 -1
  117. package/src/internal/worker-engine-profile.ts +10 -10
  118. package/src/renderer-plugin.ts +96 -0
  119. package/src/tool-preview/bootstrap.ts +1 -14
  120. package/src/types.ts +22 -77
  121. package/src/vite-env.d.ts +0 -2
  122. package/dist/__tests__/app-rebuild.test.d.ts +0 -2
  123. package/dist/__tests__/app-rebuild.test.d.ts.map +0 -1
  124. package/dist/__tests__/asset-registry-types.test-d.d.ts +0 -2
  125. package/dist/__tests__/asset-registry-types.test-d.d.ts.map +0 -1
  126. package/dist/__tests__/assets-runtime-assembly.integration.test.d.ts +0 -2
  127. package/dist/__tests__/assets-runtime-assembly.integration.test.d.ts.map +0 -1
  128. package/dist/__tests__/draw-source-headless-full-chain.test.d.ts +0 -2
  129. package/dist/__tests__/draw-source-headless-full-chain.test.d.ts.map +0 -1
  130. package/dist/__tests__/ecs-import.unit.test.d.ts +0 -2
  131. package/dist/__tests__/ecs-import.unit.test.d.ts.map +0 -1
  132. package/dist/__tests__/execution-world-health-owner.test-d.d.ts +0 -2
  133. package/dist/__tests__/execution-world-health-owner.test-d.d.ts.map +0 -1
  134. package/dist/__tests__/remote-serve-flag.test.d.ts +0 -2
  135. package/dist/__tests__/remote-serve-flag.test.d.ts.map +0 -1
  136. package/dist/assets-runtime-assembly.d.ts +0 -19
  137. package/dist/assets-runtime-assembly.d.ts.map +0 -1
  138. package/src/__tests__/app-rebuild.test.ts +0 -31
  139. package/src/__tests__/asset-registry-types.test-d.ts +0 -16
  140. package/src/__tests__/assets-runtime-assembly.integration.test.ts +0 -68
  141. package/src/__tests__/draw-source-headless-full-chain.test.ts +0 -226
  142. package/src/__tests__/ecs-import.unit.test.ts +0 -40
  143. package/src/__tests__/execution-world-health-owner.test-d.ts +0 -9
  144. package/src/__tests__/remote-serve-flag.test.ts +0 -24
  145. package/src/assets-runtime-assembly.ts +0 -70
package/src/create-app.ts CHANGED
@@ -23,40 +23,30 @@
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';
60
50
  import type { AppErrorCode, AppErrorDetailFor } from './errors';
61
51
  import { APP_ERROR_HINTS, APP_EXPECTED, AppError } from './errors';
62
52
  import {
@@ -69,6 +59,7 @@ import {
69
59
  selectExecutionTier,
70
60
  unavailableExecutionCapabilities,
71
61
  } from './execution';
62
+ import { normalizeExecutionBootstrapUrl } from './execution/bootstrap-url';
72
63
  import { createLocalExecutionControl } from './execution/control';
73
64
  import { createWorkerExecutionApp } from './execution/host-controller';
74
65
  import { assembledEngineProfile } from './internal/assembled-engine-profile';
@@ -86,6 +77,7 @@ import {
86
77
  type RhiCapture,
87
78
  } from './internal/rhi-capture';
88
79
  import { resolveRhiDebugFlag } from './internal/rhi-debug-flag';
80
+ import { createRenderFeatureHost, type RenderFeatureHost } from './renderer-plugin';
89
81
  import type {
90
82
  App,
91
83
  AppAssembleArgs,
@@ -93,35 +85,10 @@ import type {
93
85
  AssembleAppError,
94
86
  BundlerOptions,
95
87
  CanvasAppError,
96
- CanvasDrawingBufferSize,
97
88
  CreateAppOptions,
98
89
  ExecutionApp,
99
90
  } from './types';
100
91
 
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
92
  function makeAppError<C extends AppErrorCode>(
126
93
  code: C,
127
94
  expected: string,
@@ -131,23 +98,7 @@ function makeAppError<C extends AppErrorCode>(
131
98
  return new AppError({ code, expected, hint, detail }) as AppError;
132
99
  }
133
100
 
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 {
101
+ function canvasAspectPlugin(canvas: HTMLCanvasElement): Plugin {
151
102
  return {
152
103
  name: 'canvas-aspect',
153
104
  inject: ['world'],
@@ -157,7 +108,7 @@ function canvasAspectPlugin(canvas: HTMLCanvasElement, maxCanvasPixelRatio?: num
157
108
  name: 'app-sync-camera-aspect',
158
109
  queries: [],
159
110
  fn: () => {
160
- syncCanvasDrawingBuffer(canvas, maxCanvasPixelRatio);
111
+ syncCanvasDrawingBuffer(canvas);
161
112
  syncCameraAspect(ctx.world, canvas.width, canvas.height);
162
113
  },
163
114
  };
@@ -210,7 +161,8 @@ export function createApp(
210
161
  ): Promise<Result<App, CanvasAppError>>;
211
162
 
212
163
  /**
213
- * createApp({ renderer, world, input?, schedule?, ... }) -- assemble-form
164
+ * Create the browser host for the single RenderScene -> FrameReceipt path.
165
+ * `createApp({ renderer, world, input?, schedule?, ... })` is the assemble form
214
166
  * SSOT (per plan-strategy D-5). Host already owns renderer / world; the
215
167
  * returned App holds them by reference equality (per AC-02).
216
168
  *
@@ -251,20 +203,6 @@ async function createAppFromCanvas(
251
203
  // document.createElement('canvas') without appendChild. Returning
252
204
  // Result.err here short-circuits before createRenderer fires off any
253
205
  // 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
206
  if (!canvas.isConnected) {
269
207
  return err(
270
208
  makeAppError(
@@ -276,31 +214,23 @@ async function createAppFromCanvas(
276
214
  );
277
215
  }
278
216
 
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
217
  // The DOM canvas starts with a 300x150 drawing buffer even when CSS lays it
289
218
  // out at a different size. Set the physical buffer before the renderer
290
219
  // configures its swap chain; the same helper runs before each frame so a CSS
291
220
  // resize remains visible to both rendering and camera policy.
292
- syncCanvasDrawingBuffer(canvas, maxCanvasPixelRatio);
221
+ syncCanvasDrawingBuffer(canvas);
293
222
 
294
- if (opts?.execution !== undefined && normalizedExecutionBootstrap?.ok === true) {
223
+ let executionBootstrapUrl: string | undefined;
224
+ if (opts?.execution !== undefined) {
225
+ const normalizedBootstrap = normalizeExecutionBootstrapUrl(opts.execution.bootstrap);
226
+ if (!normalizedBootstrap.ok) return err(normalizedBootstrap.error);
227
+ executionBootstrapUrl = normalizedBootstrap.value;
295
228
  const realmBoundOption = [
296
229
  ['features', opts.features],
297
230
  ['plugins', opts.plugins],
298
231
  ['rhi', opts.rhi],
299
- ['rawDeviceForContextConfigure', opts.rawDeviceForContextConfigure],
232
+ ['rhiInstrumentation', opts.rhiInstrumentation],
300
233
  ['drawSource', opts.drawSource],
301
- ['membershipTiming', opts.membershipTiming],
302
- ['assetCatalog', opts.assetCatalog],
303
- ['assetDecoders', opts.assetDecoders],
304
234
  ['bundler.importTransport', bundler?.importTransport],
305
235
  ].find(([, value]) => value !== undefined)?.[0];
306
236
  if (realmBoundOption !== undefined) {
@@ -311,7 +241,7 @@ async function createAppFromCanvas(
311
241
  APP_ERROR_HINTS['app-execution-bootstrap-failed'],
312
242
  {
313
243
  phase: 'prepare',
314
- moduleUrl: normalizedExecutionBootstrap.value,
244
+ moduleUrl: executionBootstrapUrl,
315
245
  cause: new TypeError(
316
246
  `${realmBoundOption} must be constructed by the execution bootstrap module`,
317
247
  ),
@@ -328,11 +258,10 @@ async function createAppFromCanvas(
328
258
  }
329
259
  | undefined;
330
260
  let preparedExecutionBootstrap: PreparedExecutionBootstrap | undefined;
331
- if (opts?.execution !== undefined && normalizedExecutionBootstrap?.ok === true) {
332
- const executionOptions = opts.execution;
261
+ if (opts?.execution !== undefined) {
333
262
  const capabilities = await probeExecutionCapabilities(canvas);
334
263
  const selected = selectExecutionTier({
335
- requestedTier: executionOptions.tier ?? 'auto',
264
+ requestedTier: opts.execution.tier ?? 'auto',
336
265
  capabilities,
337
266
  sharedEvidencePassed: true,
338
267
  });
@@ -342,15 +271,18 @@ async function createAppFromCanvas(
342
271
  return createWorkerExecutionApp({
343
272
  canvas,
344
273
  appOptions: opts,
345
- syncCanvas: () => measureCanvasDrawingBuffer(canvas, maxCanvasPixelRatio),
274
+ syncCanvas: () => measureCanvasDrawingBuffer(canvas),
346
275
  ...(bundler !== undefined ? { bundler } : {}),
347
276
  capabilities,
348
277
  selection: selected.value,
349
278
  });
350
279
  }
280
+ if (executionBootstrapUrl === undefined) {
281
+ throw new Error('execution bootstrap URL was not normalized');
282
+ }
351
283
  const prepared = await prepareBootstrapEntry(
352
- normalizedExecutionBootstrap.value,
353
- executionOptions.bootstrapData,
284
+ executionBootstrapUrl,
285
+ opts.execution.bootstrapData,
354
286
  );
355
287
  if (!prepared.ok) return err(prepared.error);
356
288
  preparedExecutionBootstrap = prepared.value;
@@ -367,40 +299,34 @@ async function createAppFromCanvas(
367
299
  // forwarded to keep the contract honest (AI users walk the union
368
300
  // discriminant rather than parse error.message strings).
369
301
  // feat-20260608 / M2 / D-3: CreateAppOptions stops `extends RendererOptions`,
370
- // so the two RHI escape hatches (rhi / rawDeviceForContextConfigure) are
302
+ // so the RHI escape hatch (rhi) is
371
303
  // forwarded explicitly. Build a RendererOptions object out of just those
372
304
  // fields when present; an empty {} keeps createRenderer on its default path.
373
305
  const rendererOpts: import('@forgeax/engine-render').RendererOptions = {};
374
306
  if (opts?.rhi !== undefined) {
375
307
  Object.assign(rendererOpts, { rhi: opts.rhi });
376
308
  }
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
309
  if (opts?.profiler !== undefined) {
386
310
  Object.assign(rendererOpts, { profiler: opts.profiler });
387
311
  }
388
- if (opts?.membershipTiming !== undefined) {
389
- Object.assign(rendererOpts, { membershipTiming: opts.membershipTiming });
390
- }
391
312
  const rendererFeatures = preparedExecutionBootstrap?.features ?? opts?.features;
392
313
  if (rendererFeatures !== undefined) {
393
314
  Object.assign(rendererOpts, { features: rendererFeatures });
394
315
  }
316
+ if (opts?.standardProfile !== undefined) {
317
+ Object.assign(rendererOpts, { standardProfile: opts.standardProfile });
318
+ }
319
+ if (opts?.rhiInstrumentation !== undefined) {
320
+ Object.assign(rendererOpts, { rhiInstrumentation: opts.rhiInstrumentation });
321
+ }
395
322
 
396
- // Optional RHI instrumentation is assembled once and forwarded through the
397
- // typed Runtime -> Render seam. App keeps only the capture capability.
323
+ // FORGEAX_ENGINE_RHI_DEBUG=1 attaches the recorder at the Runtime backend
324
+ // seam. Render remains the owner of device/surface lifecycle; App only owns
325
+ // the optional capture capability and its frame transaction.
398
326
  let rhiAttachment: RecorderAttachment | undefined;
399
327
  let rhiCapture: RhiCapture | undefined;
400
328
  let rhiDebugGlobal:
401
- | {
402
- captureFrame(options?: CaptureFrameOptions): ReturnType<RhiCapture['captureFrame']>;
403
- }
329
+ | { captureFrame(options?: CaptureFrameOptions): ReturnType<RhiCapture['captureFrame']> }
404
330
  | undefined;
405
331
  const cleanupRhiDebugHost = (): void => {
406
332
  const host = globalThis as { __forgeax?: typeof rhiDebugGlobal };
@@ -445,65 +371,55 @@ async function createAppFromCanvas(
445
371
  : undefined;
446
372
  const hasWebGPU = nav !== undefined && 'gpu' in nav && nav.gpu !== undefined;
447
373
  if (
448
- opts?.rhi === undefined &&
449
374
  (browserBuildRhiDebugFlag === undefined || browserBuildRhiDebugFlag === '1') &&
450
375
  rhiDebugFlag === '1'
451
376
  ) {
452
- const backend = hasWebGPU
377
+ const realBackend = (hasWebGPU
453
378
  ? 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');
379
+ : await import('@forgeax/engine-rhi-wgpu')) as unknown as Record<string, unknown>;
380
+ if (!hasWebGPU && 'ensureReady' in realBackend) {
381
+ await (realBackend.ensureReady as () => Promise<unknown>)();
382
+ }
383
+ const pack = loadRhiPack(realBackend);
458
384
  if (pack.createShaderModule === undefined) {
459
- throw new Error('RHI debug capture requires the backend shader-module entry');
385
+ throw new Error('RHI-debug requires a backend createShaderModule capability');
460
386
  }
461
- const createShaderModule: CreateShaderModuleFn = pack.createShaderModule;
462
- const attached = debugModule.attachRecorder({
387
+ const attached = attachRecorder({
463
388
  rhi: pack.rhi,
464
- createShaderModule,
389
+ createShaderModule: pack.createShaderModule,
465
390
  });
466
391
  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
-
392
+ rhiAttachment = attached.value;
393
+ rhiCapture = createRhiCapture(attached.value);
394
+ const capture = rhiCapture;
395
+ rhiDebugGlobal = { captureFrame: (options) => capture.captureFrame(options) };
472
396
  Object.assign(rendererOpts, {
473
- rhi: attachment.backend.rhi,
474
- rhiInstrumentation: createRhiInstrumentation(attachment),
397
+ rhi: attached.value.backend.rhi,
398
+ rhiInstrumentation: createRhiInstrumentation(attached.value),
475
399
  });
476
-
477
- rhiDebugGlobal = {
478
- captureFrame: (options) => capture.captureFrame(options),
479
- };
480
400
  (globalThis as { __forgeax?: typeof rhiDebugGlobal }).__forgeax = rhiDebugGlobal;
481
401
  }
482
402
 
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
403
  let renderer: Renderer;
404
+ let rendererDebugDrawHost: RendererHostAssembly['debugDrawHost'];
405
+ let rendererFeatureHost: RenderFeatureHost;
406
+ let assets: AssetRegistry;
490
407
  try {
491
- renderer = await createRenderer(canvas, rendererOpts, {
492
- ...bundler,
493
- assetResolver: getAssetRegistryResolver(assetAssembly.registry),
494
- });
408
+ const constructed = await constructRuntimeRendererHost(canvas, rendererOpts, bundler);
409
+ if (!constructed.ok) throw constructed.error;
410
+ renderer = constructed.value.renderer;
411
+ rendererDebugDrawHost = constructed.value.debugDrawHost;
412
+ rendererFeatureHost = createRenderFeatureHost(constructed.value.featureHost);
413
+ assets = constructed.value.assets;
495
414
  } catch (e: unknown) {
496
415
  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;
416
+ if (e instanceof EngineEnvironmentError) return err(e);
417
+ const detail = e instanceof Error ? e : new Error(String(e));
418
+ return err(
419
+ new EngineEnvironmentError('renderer construction failed', {
420
+ webgpuError: detail,
421
+ }),
422
+ );
507
423
  }
508
424
 
509
425
  // Step 2.4 decision: resolve whether the remote eval server should start
@@ -516,7 +432,6 @@ async function createAppFromCanvas(
516
432
  ? (import.meta as { env?: { DEV?: boolean } }).env?.DEV
517
433
  : undefined,
518
434
  (globalThis as { process?: { env?: { FORGEAX_ENGINE_REMOTE_SERVE?: string } } }).process?.env,
519
- typeof window !== 'undefined',
520
435
  );
521
436
 
522
437
  let debugDraw: DebugDraw | undefined;
@@ -564,8 +479,10 @@ async function createAppFromCanvas(
564
479
  world,
565
480
  mainEngineProfile({
566
481
  renderer,
567
- assetAssembly,
568
- animationPayloads: createAnimationPayloadLookup(renderer.assets),
482
+ rendererDebugDrawHost,
483
+ rendererFeatureHost,
484
+ assets,
485
+ animationPayloads: createAnimationPayloadLookup(assets),
569
486
  ...(inputBackend === undefined ? {} : { input: inputBackend }),
570
487
  ...(inputHandle === undefined ? {} : { inputDispose: inputHandle.cleanup }),
571
488
  ...(opts?.inputMap === undefined ? {} : { inputMap: opts.inputMap }),
@@ -575,14 +492,12 @@ async function createAppFromCanvas(
575
492
  extensions: [
576
493
  rhiDebugHostPlugin(cleanupRhiDebugHost),
577
494
  ...userPlugins,
578
- canvasAspectPlugin(canvas, maxCanvasPixelRatio),
495
+ canvasAspectPlugin(canvas),
579
496
  ],
580
497
  }),
581
498
  );
582
499
  } catch (cause) {
583
500
  cleanupRhiDebugHost();
584
- assetAssembly.dispose();
585
- assetAssembly.registry.dispose();
586
501
  return err(
587
502
  makeAppError(
588
503
  'app-plugin-activation-failed',
@@ -639,20 +554,13 @@ async function createAppFromCanvas(
639
554
  // of static dep on @forgeax/engine-remote. Component reflection crosses
640
555
  // this boundary as JSON-safe host data; app does not own any component.
641
556
  const remoteHandle = shouldStartRemote
642
- ? await startRemoteServer(
643
- world,
644
- renderer,
645
- assetAssembly.registry,
646
- rhiCapture,
647
- opts?.profiler,
648
- executionControl,
649
- )
557
+ ? await startRemoteServer(world, renderer, assets, rhiCapture, opts?.profiler, executionControl)
650
558
  : undefined;
651
559
  if (remoteHandle !== undefined) await pluginContext.plugin(remoteServerPlugin(remoteHandle));
652
560
 
653
561
  const buildArgs: BuildAppArgs = {
654
562
  renderer,
655
- assets: assetAssembly.registry,
563
+ assets,
656
564
  world,
657
565
  pluginContext,
658
566
  executionControl,
@@ -681,10 +589,7 @@ async function createAppFromCanvas(
681
589
  Object.assign(buildArgs, { profiler: opts.profiler });
682
590
  }
683
591
  if (rhiCapture !== undefined) {
684
- Object.assign(buildArgs, {
685
- rhiCapture,
686
- ...(rhiAttachment === undefined ? {} : { rhiAttachment }),
687
- });
592
+ Object.assign(buildArgs, { rhiCapture });
688
593
  }
689
594
  if (debugDraw !== undefined) {
690
595
  Object.assign(buildArgs, { debugDraw });
@@ -727,17 +632,17 @@ async function createAppFromCanvas(
727
632
  ?.VITE_FORGEAX_ENGINE_BRIDGE_PORT ?? '5733';
728
633
  await pluginContext.plugin({
729
634
  name: 'browser-remote-bridge',
730
- inject: ['world', 'renderer', 'assetRegistry'],
635
+ inject: ['world', 'renderer', 'assets'],
731
636
  async apply(ctx) {
732
- if (ctx.renderer === undefined || ctx.assetRegistry === undefined) {
733
- throw new Error('browser remote bridge requires renderer and assetRegistry services');
637
+ if (ctx.renderer === undefined || ctx.assets === undefined) {
638
+ throw new Error('browser remote bridge requires renderer and assets services');
734
639
  }
735
640
  try {
736
641
  const bridge = await import('./internal/browser-remote-bridge');
737
642
  const teardown = await bridge.installBrowserRemoteBridge({
738
643
  world: ctx.world,
739
644
  renderer: ctx.renderer,
740
- assets: ctx.assetRegistry,
645
+ assets: ctx.assets,
741
646
  runtimeModule: engineRuntimeModule,
742
647
  ...(rhiCapture !== undefined ? { rhiCapture } : {}),
743
648
  ...(opts?.profiler !== undefined ? { profiler: opts.profiler } : {}),
@@ -764,47 +669,14 @@ export function syncCanvasDrawingBuffer(
764
669
  canvas: Pick<HTMLCanvasElement, 'clientWidth' | 'clientHeight' | 'width' | 'height'> & {
765
670
  readonly style?: Pick<CSSStyleDeclaration, 'width' | 'height'>;
766
671
  },
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 {
672
+ ): void {
801
673
  if (
802
674
  !Number.isFinite(canvas.clientWidth) ||
803
675
  !Number.isFinite(canvas.clientHeight) ||
804
676
  canvas.clientWidth <= 0 ||
805
677
  canvas.clientHeight <= 0
806
678
  )
807
- return undefined;
679
+ return;
808
680
  // An intrinsic canvas with no CSS dimensions already has an explicit
809
681
  // drawing-buffer contract. `clientWidth` mirrors `width` for this shape;
810
682
  // multiplying it by DPR would change the caller's requested pixel size and
@@ -818,15 +690,9 @@ function resolveCanvasDrawingBufferSize(
818
690
  canvas.clientWidth === canvas.width &&
819
691
  canvas.clientHeight === canvas.height
820
692
  ) {
821
- return { width: canvas.width, height: canvas.height };
693
+ return;
822
694
  }
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);
695
+ const dpr = Math.max(1, globalThis.devicePixelRatio || 1);
830
696
  const previous = syncedCanvasSizes.get(canvas);
831
697
  const drawingBufferIsTheLayoutMeasurement =
832
698
  previous !== undefined &&
@@ -838,13 +704,60 @@ function resolveCanvasDrawingBufferSize(
838
704
  const cssHeight = drawingBufferIsTheLayoutMeasurement ? previous.cssHeight : canvas.clientHeight;
839
705
  const width = Math.max(1, Math.round(cssWidth * dpr));
840
706
  const height = Math.max(1, Math.round(cssHeight * dpr));
707
+ if (canvas.width !== width) canvas.width = width;
708
+ if (canvas.height !== height) canvas.height = height;
841
709
  syncedCanvasSizes.set(canvas, {
842
710
  cssWidth,
843
711
  cssHeight,
844
712
  drawingWidth: width,
845
713
  drawingHeight: height,
846
714
  });
847
- return { width, height };
715
+ }
716
+
717
+ /** Measure a CSS-sized canvas without mutating its drawing buffer. */
718
+ export function measureCanvasDrawingBuffer(
719
+ canvas: Pick<HTMLCanvasElement, 'clientWidth' | 'clientHeight' | 'width' | 'height'> & {
720
+ readonly style?: Pick<CSSStyleDeclaration, 'width' | 'height'>;
721
+ },
722
+ maxCanvasPixelRatio?: number,
723
+ ): { readonly width: number; readonly height: number } {
724
+ if (
725
+ !Number.isFinite(canvas.clientWidth) ||
726
+ !Number.isFinite(canvas.clientHeight) ||
727
+ canvas.clientWidth <= 0 ||
728
+ canvas.clientHeight <= 0
729
+ ) {
730
+ return { width: canvas.width, height: canvas.height };
731
+ }
732
+ const hasExplicitCssSize =
733
+ (canvas.style?.width ?? '') !== '' || (canvas.style?.height ?? '') !== '';
734
+ if (
735
+ !hasExplicitCssSize &&
736
+ canvas.clientWidth === canvas.width &&
737
+ canvas.clientHeight === canvas.height
738
+ ) {
739
+ return { width: canvas.width, height: canvas.height };
740
+ }
741
+ const devicePixelRatio = Math.max(1, globalThis.devicePixelRatio || 1);
742
+ const dpr =
743
+ maxCanvasPixelRatio === undefined ||
744
+ !Number.isFinite(maxCanvasPixelRatio) ||
745
+ maxCanvasPixelRatio <= 0
746
+ ? devicePixelRatio
747
+ : Math.min(devicePixelRatio, maxCanvasPixelRatio);
748
+ const previous = syncedCanvasSizes.get(canvas);
749
+ const drawingBufferIsTheLayoutMeasurement =
750
+ previous !== undefined &&
751
+ canvas.width === previous.drawingWidth &&
752
+ canvas.height === previous.drawingHeight &&
753
+ canvas.clientWidth === previous.drawingWidth &&
754
+ canvas.clientHeight === previous.drawingHeight;
755
+ const cssWidth = drawingBufferIsTheLayoutMeasurement ? previous.cssWidth : canvas.clientWidth;
756
+ const cssHeight = drawingBufferIsTheLayoutMeasurement ? previous.cssHeight : canvas.clientHeight;
757
+ return {
758
+ width: Math.max(1, Math.round(cssWidth * dpr)),
759
+ height: Math.max(1, Math.round(cssHeight * dpr)),
760
+ };
848
761
  }
849
762
 
850
763
  const syncedCanvasSizes = new WeakMap<
@@ -857,10 +770,49 @@ const syncedCanvasSizes = new WeakMap<
857
770
  }
858
771
  >();
859
772
 
773
+ /**
774
+ * Per-frame aspect-sync body for the createApp(canvas) path (feat-20260617
775
+ * M3 / w13). Walks every Camera entity and, for perspective cameras with
776
+ * `autoAspect === true`, writes `canvasW / canvasH` into `Camera.aspect`.
777
+ *
778
+ * Read discipline (D-5 / research Finding 2): `autoAspect` is read through
779
+ * `world.get` (the readRow path narrows the bool column to a JS boolean).
780
+ * The query bundle is used only to enumerate the entity handles -- reading
781
+ * the bool column off the bundle would return a raw 0/1 number, so a
782
+ * `!== 0` test is always true (the
783
+ * bool-field-compared-with-not-equal-zero-always-true trap).
784
+ *
785
+ * Best-effort + side-effect-isolated:
786
+ * - canvas size 0 (detached / display:none) -> skip entirely so `aspect`
787
+ * never becomes NaN / 0.
788
+ * - orthographic cameras and `autoAspect === false` cameras are left
789
+ * untouched.
790
+ *
791
+ * @see {@link https://github.com/Ubpa/forgeax-engine/blob/main/docs/how-to/2026-06-18-host-engine-contract.md | Host-engine contract SSOT}
792
+ */
793
+ export function syncCameraAspect(world: World, canvasW: number, canvasH: number): void {
794
+ // Guard against detached / zero-sized canvases: a 0 width or height would
795
+ // write NaN (0 / 0) or 0 into aspect and corrupt the projection matrix.
796
+ if (canvasW <= 0 || canvasH <= 0) return;
797
+ const aspect = canvasW / canvasH;
798
+
799
+ const query = world.query({ with: [Camera] }).unwrap();
800
+ for (const row of query) {
801
+ const entity = row.entity;
802
+ const r = world.get(entity, Camera);
803
+ if (!r.ok) continue;
804
+ // world.get narrows the bool column to a real boolean (D-5); the
805
+ // perspective discriminator is the numeric column value.
806
+ if (r.value.autoAspect !== true) continue;
807
+ if (r.value.projection !== CAMERA_PROJECTION_PERSPECTIVE) continue;
808
+ world.set(entity, Camera, { aspect });
809
+ }
810
+ }
811
+
860
812
  async function startRemoteServer(
861
813
  world: World,
862
814
  renderer: Renderer,
863
- assets: AssetRegistry,
815
+ assets: AssetRegistry | undefined,
864
816
  rhiCapture: RhiCapture | undefined,
865
817
  profiler: import('@forgeax/engine-profiler').Profiler | undefined,
866
818
  execution: ExecutionControl,
@@ -907,22 +859,17 @@ async function startRemoteServer(
907
859
  async function createAppFromAssemble(
908
860
  args: AppAssembleArgs,
909
861
  ): 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
862
  let pluginContext: Context;
914
863
  try {
915
864
  pluginContext = await createWorldContext(
916
865
  args.world,
917
866
  assembledEngineProfile({
918
867
  renderer: args.renderer,
919
- assetAssembly,
868
+ ...(args.assets === undefined ? {} : { assets: args.assets }),
920
869
  extensions: args.plugins ?? [],
921
870
  }),
922
871
  );
923
872
  } catch (cause) {
924
- assetAssembly.dispose();
925
- assetAssembly.registry.dispose();
926
873
  return err(
927
874
  makeAppError(
928
875
  'app-plugin-activation-failed',
@@ -968,7 +915,7 @@ async function createAppFromAssemble(
968
915
  ? await startRemoteServer(
969
916
  args.world,
970
917
  args.renderer,
971
- assetAssembly.registry,
918
+ args.assets,
972
919
  undefined,
973
920
  args.profiler,
974
921
  executionControl,
@@ -978,7 +925,6 @@ async function createAppFromAssemble(
978
925
 
979
926
  const buildArgs: BuildAppArgs = {
980
927
  renderer: args.renderer,
981
- assets: assetAssembly.registry,
982
928
  world: args.world,
983
929
  pluginContext,
984
930
  executionControl,
@@ -1013,7 +959,7 @@ async function createAppFromAssemble(
1013
959
 
1014
960
  interface BuildAppArgs {
1015
961
  readonly renderer: Renderer;
1016
- readonly assets: AssetRegistry;
962
+ readonly assets?: AssetRegistry;
1017
963
  readonly world: World;
1018
964
  readonly pluginContext: Context;
1019
965
  readonly inputBackend?: InputBackend;
@@ -1027,10 +973,8 @@ interface BuildAppArgs {
1027
973
  readonly wireOnLockErrorDispatch?: (dispatch: (err: AppError) => void) => void;
1028
974
  readonly audioBackend?: AudioBackend;
1029
975
  readonly silenceUnhandledErrors?: boolean;
1030
- /** M4: host-owned RHI capture capability attached to the app surface. */
976
+ /** Optional App-owned RHI capture capability. */
1031
977
  readonly rhiCapture?: RhiCapture;
1032
- /** Internal attachment used to bind capture to this App's frame authority. */
1033
- readonly rhiAttachment?: RecorderAttachment;
1034
978
  /** feat-20260615 debug-draw M5: DebugDraw instance created by createDebugDrawOnReady. */
1035
979
  readonly debugDraw?: DebugDraw;
1036
980
  /** feat-20260629 M4 / w20: remote eval server handle from createAppFromCanvas. */
@@ -1060,14 +1004,12 @@ interface BuildAppArgs {
1060
1004
  async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiError>> {
1061
1005
  const {
1062
1006
  renderer,
1063
- assets,
1064
1007
  world,
1065
1008
  pluginContext,
1066
1009
  inputBackend,
1067
1010
  audioBackend,
1068
1011
  silenceUnhandledErrors,
1069
1012
  rhiCapture,
1070
- rhiAttachment,
1071
1013
  debugDraw,
1072
1014
  remoteHandle,
1073
1015
  profiler,
@@ -1088,6 +1030,7 @@ async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiE
1088
1030
  const fanout = new ErrorFanoutRegistry(
1089
1031
  silenceUnhandledErrors !== undefined ? { silenceUnhandledErrors } : {},
1090
1032
  );
1033
+ let lastError: AppDispatchError | undefined;
1091
1034
  const execution =
1092
1035
  args.executionControl ??
1093
1036
  createLocalExecutionControl(
@@ -1116,6 +1059,7 @@ async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiE
1116
1059
  );
1117
1060
 
1118
1061
  function dispatch(e: AppDispatchError): void {
1062
+ lastError = e;
1119
1063
  fanout.fire(e);
1120
1064
  }
1121
1065
 
@@ -1141,8 +1085,7 @@ async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiE
1141
1085
  Object.assign(loopOpts, { profiler });
1142
1086
  }
1143
1087
  const loop = createFrameLoop(loopOpts);
1144
-
1145
- if (rhiCapture !== undefined && rhiAttachment !== undefined) {
1088
+ if (rhiCapture !== undefined) {
1146
1089
  bindRhiCaptureFrameDriver(rhiCapture, {
1147
1090
  getState: () => loop.getState(),
1148
1091
  pause: () => {
@@ -1158,9 +1101,6 @@ async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiE
1158
1101
  stepFrame: (deltaSeconds) => loop.stepFrame(deltaSeconds),
1159
1102
  });
1160
1103
  }
1161
-
1162
- let lastError: AppDispatchError | undefined;
1163
-
1164
1104
  // M4 (w13) device-lost internal subscription. R-1 timing contract:
1165
1105
  // app.start() arms the rAF handle BEFORE this listener subscribes, so
1166
1106
  // a synchronous late-attach replay of a persisted device-lost event
@@ -1177,32 +1117,9 @@ async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiE
1177
1117
  if (rendererUnsubscribe !== undefined) {
1178
1118
  return;
1179
1119
  }
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;
1202
- }
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).
1120
+ rendererUnsubscribe = renderer.subscribe((event) => {
1121
+ if (event.kind !== 'error') return;
1122
+ const e: RenderError = event.error;
1206
1123
  dispatch(e);
1207
1124
  });
1208
1125
  }
@@ -1216,10 +1133,10 @@ async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiE
1216
1133
 
1217
1134
  const stub: App = {
1218
1135
  renderer,
1219
- assets,
1136
+ ...(args.assets === undefined ? {} : { assets: args.assets }),
1220
1137
  world,
1221
1138
  execution,
1222
- async releaseSurfacePreserveWorld(): Promise<Result<void, RhiError>> {
1139
+ async releaseSurfacePreserveWorld(): Promise<Result<void, RhiError | RenderError>> {
1223
1140
  if (loop.getState() === 'running') {
1224
1141
  const paused = loop.pause();
1225
1142
  if (!paused.ok) {
@@ -1246,7 +1163,7 @@ async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiE
1246
1163
  }
1247
1164
  return released.ok ? ok(undefined) : err(released.error);
1248
1165
  },
1249
- async restoreSurface(): Promise<Result<void, RhiError>> {
1166
+ async restoreSurface(): Promise<Result<void, RhiError | RenderError>> {
1250
1167
  const restored = renderer.restoreSurface();
1251
1168
  if (!restored.ok) return err(restored.error);
1252
1169
  if (resumeAfterSurfaceRestore) {
@@ -1276,7 +1193,7 @@ async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiE
1276
1193
  },
1277
1194
  start(): Result<void, AppError> {
1278
1195
  // R-1: arm the rAF handle FIRST (loop.start schedules raf(tick))
1279
- // and only THEN subscribe to renderer.onError. If the renderer
1196
+ // and only THEN subscribe to renderer events. If the renderer
1280
1197
  // late-attach replays a persisted device-lost event during the
1281
1198
  // subscribe call, the loop is already armed and can retain the error.
1282
1199
  const r = loop.start();
@@ -1321,7 +1238,7 @@ async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiE
1321
1238
  loop.setDrawSource(drawSource);
1322
1239
  },
1323
1240
  /**
1324
- * Most recent device-loss error retained for host self-inspection.
1241
+ * Most recent dispatched error retained for host self-inspection.
1325
1242
  */
1326
1243
  get lastError(): AppDispatchError | undefined {
1327
1244
  return lastError;
@@ -1331,21 +1248,5 @@ async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiE
1331
1248
  ...(remoteHandle !== undefined ? { remote: remoteHandle } : {}),
1332
1249
  };
1333
1250
 
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
1251
  return ok(stub);
1351
1252
  }