@forgeax/engine-app 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +418 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/animation-asset-lookup.unit.test.d.ts +2 -0
- package/dist/__tests__/animation-asset-lookup.unit.test.d.ts.map +1 -0
- package/dist/__tests__/app-rebuild.test.d.ts +2 -0
- package/dist/__tests__/app-rebuild.test.d.ts.map +1 -0
- package/dist/__tests__/asset-registry-types.test-d.d.ts +2 -0
- package/dist/__tests__/asset-registry-types.test-d.d.ts.map +1 -0
- package/dist/__tests__/assets-runtime-assembly.integration.test.d.ts +2 -0
- package/dist/__tests__/assets-runtime-assembly.integration.test.d.ts.map +1 -0
- package/dist/__tests__/callback-deletion.unit.test.d.ts +2 -0
- package/dist/__tests__/callback-deletion.unit.test.d.ts.map +1 -0
- package/dist/__tests__/callback-inference.test-d.d.ts +2 -0
- package/dist/__tests__/callback-inference.test-d.d.ts.map +1 -0
- package/dist/__tests__/component-introspection.unit.test.d.ts +2 -0
- package/dist/__tests__/component-introspection.unit.test.d.ts.map +1 -0
- package/dist/__tests__/consumer-migration.unit.test.d.ts +2 -0
- package/dist/__tests__/consumer-migration.unit.test.d.ts.map +1 -0
- package/dist/__tests__/create-app-features.test.d.ts +2 -0
- package/dist/__tests__/create-app-features.test.d.ts.map +1 -0
- package/dist/__tests__/create-app-listener-sync.test.d.ts +2 -0
- package/dist/__tests__/create-app-listener-sync.test.d.ts.map +1 -0
- package/dist/__tests__/create-app-lock-provider.test.d.ts +2 -0
- package/dist/__tests__/create-app-lock-provider.test.d.ts.map +1 -0
- package/dist/__tests__/create-app-plugin.test.d.ts +2 -0
- package/dist/__tests__/create-app-plugin.test.d.ts.map +1 -0
- package/dist/__tests__/create-app-stop.test.d.ts +2 -0
- package/dist/__tests__/create-app-stop.test.d.ts.map +1 -0
- package/dist/__tests__/create-app-time.unit.test.d.ts +2 -0
- package/dist/__tests__/create-app-time.unit.test.d.ts.map +1 -0
- package/dist/__tests__/create-app.test-d.d.ts +2 -0
- package/dist/__tests__/create-app.test-d.d.ts.map +1 -0
- package/dist/__tests__/create-app.test.d.ts +2 -0
- package/dist/__tests__/create-app.test.d.ts.map +1 -0
- package/dist/__tests__/create-renderer-error.integration.test.d.ts +2 -0
- package/dist/__tests__/create-renderer-error.integration.test.d.ts.map +1 -0
- package/dist/__tests__/create-renderer-lifecycle.integration.test.d.ts +2 -0
- package/dist/__tests__/create-renderer-lifecycle.integration.test.d.ts.map +1 -0
- package/dist/__tests__/docs-gate-green.unit.test.d.ts +2 -0
- package/dist/__tests__/docs-gate-green.unit.test.d.ts.map +1 -0
- package/dist/__tests__/draw-source-headless-full-chain.test.d.ts +2 -0
- package/dist/__tests__/draw-source-headless-full-chain.test.d.ts.map +1 -0
- package/dist/__tests__/draw-source-injected-world-update-visibility.test.d.ts +2 -0
- package/dist/__tests__/draw-source-injected-world-update-visibility.test.d.ts.map +1 -0
- package/dist/__tests__/draw-source-single-world-regression.test.d.ts +2 -0
- package/dist/__tests__/draw-source-single-world-regression.test.d.ts.map +1 -0
- package/dist/__tests__/ecs-import.unit.test.d.ts +2 -0
- package/dist/__tests__/ecs-import.unit.test.d.ts.map +1 -0
- package/dist/__tests__/engine-worker-bootstrap-channel.unit.test.d.ts +2 -0
- package/dist/__tests__/engine-worker-bootstrap-channel.unit.test.d.ts.map +1 -0
- package/dist/__tests__/engine-worker-world-swap.unit.test.d.ts +2 -0
- package/dist/__tests__/engine-worker-world-swap.unit.test.d.ts.map +1 -0
- package/dist/__tests__/errors-pointer-lock-failed.test.d.ts +2 -0
- package/dist/__tests__/errors-pointer-lock-failed.test.d.ts.map +1 -0
- package/dist/__tests__/execution-bootstrap-isolation.unit.test.d.ts +2 -0
- package/dist/__tests__/execution-bootstrap-isolation.unit.test.d.ts.map +1 -0
- package/dist/__tests__/execution-error-detail.test-d.d.ts +2 -0
- package/dist/__tests__/execution-error-detail.test-d.d.ts.map +1 -0
- package/dist/__tests__/execution-error-detail.unit.test.d.ts +2 -0
- package/dist/__tests__/execution-error-detail.unit.test.d.ts.map +1 -0
- package/dist/__tests__/execution-frame-order.unit.test.d.ts +2 -0
- package/dist/__tests__/execution-frame-order.unit.test.d.ts.map +1 -0
- package/dist/__tests__/execution-frame-protocol.unit.test.d.ts +2 -0
- package/dist/__tests__/execution-frame-protocol.unit.test.d.ts.map +1 -0
- package/dist/__tests__/execution-input-sample.contract.test.d.ts +2 -0
- package/dist/__tests__/execution-input-sample.contract.test.d.ts.map +1 -0
- package/dist/__tests__/execution-measurement.unit.test.d.ts +2 -0
- package/dist/__tests__/execution-measurement.unit.test.d.ts.map +1 -0
- package/dist/__tests__/execution-public-api.test-d.d.ts +2 -0
- package/dist/__tests__/execution-public-api.test-d.d.ts.map +1 -0
- package/dist/__tests__/execution-public-api.unit.test.d.ts +2 -0
- package/dist/__tests__/execution-public-api.unit.test.d.ts.map +1 -0
- package/dist/__tests__/execution-rebuild.contract.test.d.ts +2 -0
- package/dist/__tests__/execution-rebuild.contract.test.d.ts.map +1 -0
- package/dist/__tests__/execution-report.contract.test.d.ts +2 -0
- package/dist/__tests__/execution-report.contract.test.d.ts.map +1 -0
- package/dist/__tests__/execution-report.types.test-d.d.ts +2 -0
- package/dist/__tests__/execution-report.types.test-d.d.ts.map +1 -0
- package/dist/__tests__/execution-schema-freshness.unit.test.d.ts +2 -0
- package/dist/__tests__/execution-schema-freshness.unit.test.d.ts.map +1 -0
- package/dist/__tests__/execution-selector.unit.test.d.ts +2 -0
- package/dist/__tests__/execution-selector.unit.test.d.ts.map +1 -0
- package/dist/__tests__/execution-shared-kernel.unit.test.d.ts +2 -0
- package/dist/__tests__/execution-shared-kernel.unit.test.d.ts.map +1 -0
- package/dist/__tests__/execution-world-health-owner.test-d.d.ts +2 -0
- package/dist/__tests__/execution-world-health-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/fallback-camera.unit.test.d.ts +2 -0
- package/dist/__tests__/fallback-camera.unit.test.d.ts.map +1 -0
- package/dist/__tests__/frame-loop-time-elapsed.test.d.ts +2 -0
- package/dist/__tests__/frame-loop-time-elapsed.test.d.ts.map +1 -0
- package/dist/__tests__/frame-loop-time.unit.test.d.ts +2 -0
- package/dist/__tests__/frame-loop-time.unit.test.d.ts.map +1 -0
- package/dist/__tests__/frame-loop-world-array.test.d.ts +2 -0
- package/dist/__tests__/frame-loop-world-array.test.d.ts.map +1 -0
- package/dist/__tests__/kernel-worker-runtime.unit.test.d.ts +2 -0
- package/dist/__tests__/kernel-worker-runtime.unit.test.d.ts.map +1 -0
- package/dist/__tests__/migration-surface-inventory.unit.test.d.ts +2 -0
- package/dist/__tests__/migration-surface-inventory.unit.test.d.ts.map +1 -0
- package/dist/__tests__/plugin-policy.integration.test.d.ts +2 -0
- package/dist/__tests__/plugin-policy.integration.test.d.ts.map +1 -0
- package/dist/__tests__/plugin-profiles.unit.test.d.ts +2 -0
- package/dist/__tests__/plugin-profiles.unit.test.d.ts.map +1 -0
- package/dist/__tests__/profiler-default-off.test.d.ts +2 -0
- package/dist/__tests__/profiler-default-off.test.d.ts.map +1 -0
- package/dist/__tests__/profiler-frame-token.test.d.ts +2 -0
- package/dist/__tests__/profiler-frame-token.test.d.ts.map +1 -0
- package/dist/__tests__/profiler-phase-catalog.test.d.ts +2 -0
- package/dist/__tests__/profiler-phase-catalog.test.d.ts.map +1 -0
- package/dist/__tests__/profiler-phase-integration.test.d.ts +2 -0
- package/dist/__tests__/profiler-phase-integration.test.d.ts.map +1 -0
- package/dist/__tests__/profiler-remote-bridge.test.d.ts +2 -0
- package/dist/__tests__/profiler-remote-bridge.test.d.ts.map +1 -0
- package/dist/__tests__/remote-integration.test.d.ts +2 -0
- package/dist/__tests__/remote-integration.test.d.ts.map +1 -0
- package/dist/__tests__/remote-serve-flag.test.d.ts +2 -0
- package/dist/__tests__/remote-serve-flag.test.d.ts.map +1 -0
- package/dist/__tests__/rhi-capture-capability.test-d.d.ts +2 -0
- package/dist/__tests__/rhi-capture-capability.test-d.d.ts.map +1 -0
- package/dist/__tests__/rhi-capture-host.integration.test.d.ts +2 -0
- package/dist/__tests__/rhi-capture-host.integration.test.d.ts.map +1 -0
- package/dist/__tests__/rhi-capture.browser.test.d.ts +2 -0
- package/dist/__tests__/rhi-capture.browser.test.d.ts.map +1 -0
- package/dist/__tests__/surface-handoff.test.d.ts +2 -0
- package/dist/__tests__/surface-handoff.test.d.ts.map +1 -0
- package/dist/__tests__/types-m2.test-d.d.ts +2 -0
- package/dist/__tests__/types-m2.test-d.d.ts.map +1 -0
- package/dist/__tests__/visibility-remote.integration.test.d.ts +2 -0
- package/dist/__tests__/visibility-remote.integration.test.d.ts.map +1 -0
- package/dist/__tests__/worker-execution-stop.test.d.ts +2 -0
- package/dist/__tests__/worker-execution-stop.test.d.ts.map +1 -0
- package/dist/animation-asset-lookup.d.ts +8 -0
- package/dist/animation-asset-lookup.d.ts.map +1 -0
- package/dist/assets-runtime-assembly.d.ts +19 -0
- package/dist/assets-runtime-assembly.d.ts.map +1 -0
- package/dist/canvas-policy.d.ts +4 -0
- package/dist/canvas-policy.d.ts.map +1 -0
- package/dist/create-app.d.ts +57 -0
- package/dist/create-app.d.ts.map +1 -0
- package/dist/engine-worker-runtime.mjs +884 -0
- package/dist/engine-worker-runtime.mjs.map +1 -0
- package/dist/errors.d.ts +263 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/execution/attached-world-swap.d.ts +21 -0
- package/dist/execution/attached-world-swap.d.ts.map +1 -0
- package/dist/execution/bootstrap-entry.d.ts +30 -0
- package/dist/execution/bootstrap-entry.d.ts.map +1 -0
- package/dist/execution/capabilities.d.ts +5 -0
- package/dist/execution/capabilities.d.ts.map +1 -0
- package/dist/execution/control.d.ts +14 -0
- package/dist/execution/control.d.ts.map +1 -0
- package/dist/execution/engine-worker-runtime.d.ts +2 -0
- package/dist/execution/engine-worker-runtime.d.ts.map +1 -0
- package/dist/execution/engine-worker.d.ts +23 -0
- package/dist/execution/engine-worker.d.ts.map +1 -0
- package/dist/execution/host-controller.d.ts +16 -0
- package/dist/execution/host-controller.d.ts.map +1 -0
- package/dist/execution/index.d.ts +10 -0
- package/dist/execution/index.d.ts.map +1 -0
- package/dist/execution/kernel-pool.d.ts +14 -0
- package/dist/execution/kernel-pool.d.ts.map +1 -0
- package/dist/execution/kernel-worker-runtime.d.ts +2 -0
- package/dist/execution/kernel-worker-runtime.d.ts.map +1 -0
- package/dist/execution/measurement.d.ts +7 -0
- package/dist/execution/measurement.d.ts.map +1 -0
- package/dist/execution/protocol.d.ts +84 -0
- package/dist/execution/protocol.d.ts.map +1 -0
- package/dist/execution/report.d.ts +5 -0
- package/dist/execution/report.d.ts.map +1 -0
- package/dist/execution/schema.d.ts +4 -0
- package/dist/execution/schema.d.ts.map +1 -0
- package/dist/execution/selector.d.ts +10 -0
- package/dist/execution/selector.d.ts.map +1 -0
- package/dist/execution/types.d.ts +107 -0
- package/dist/execution/types.d.ts.map +1 -0
- package/dist/fallback-camera.d.ts +8 -0
- package/dist/fallback-camera.d.ts.map +1 -0
- package/dist/game-context.d.ts +98 -0
- package/dist/game-context.d.ts.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +3985 -0
- package/dist/index.mjs.map +1 -0
- package/dist/input-map-plugin.d.ts +5 -0
- package/dist/input-map-plugin.d.ts.map +1 -0
- package/dist/input-plugin.d.ts +4 -0
- package/dist/input-plugin.d.ts.map +1 -0
- package/dist/internal/assembled-engine-profile.d.ts +5 -0
- package/dist/internal/assembled-engine-profile.d.ts.map +1 -0
- package/dist/internal/assets-world-plugin.d.ts +21 -0
- package/dist/internal/assets-world-plugin.d.ts.map +1 -0
- package/dist/internal/browser-remote-bridge.d.ts +25 -0
- package/dist/internal/browser-remote-bridge.d.ts.map +1 -0
- package/dist/internal/browser-rhi-debug-runtime.d.ts +18 -0
- package/dist/internal/browser-rhi-debug-runtime.d.ts.map +1 -0
- package/dist/internal/component-introspection.d.ts +24 -0
- package/dist/internal/component-introspection.d.ts.map +1 -0
- package/dist/internal/ecs-import.d.ts +6 -0
- package/dist/internal/ecs-import.d.ts.map +1 -0
- package/dist/internal/engine-profile-common.d.ts +9 -0
- package/dist/internal/engine-profile-common.d.ts.map +1 -0
- package/dist/internal/error-fanout.d.ts +66 -0
- package/dist/internal/error-fanout.d.ts.map +1 -0
- package/dist/internal/frame-loop.d.ts +35 -0
- package/dist/internal/frame-loop.d.ts.map +1 -0
- package/dist/internal/input-attach.d.ts +44 -0
- package/dist/internal/input-attach.d.ts.map +1 -0
- package/dist/internal/main-engine-profile.d.ts +15 -0
- package/dist/internal/main-engine-profile.d.ts.map +1 -0
- package/dist/internal/remote-serve-flag.d.ts +23 -0
- package/dist/internal/remote-serve-flag.d.ts.map +1 -0
- package/dist/internal/remote-server-plugin.d.ts +8 -0
- package/dist/internal/remote-server-plugin.d.ts.map +1 -0
- package/dist/internal/rhi-capture.d.ts +19 -0
- package/dist/internal/rhi-capture.d.ts.map +1 -0
- package/dist/internal/rhi-debug-flag.d.ts +18 -0
- package/dist/internal/rhi-debug-flag.d.ts.map +1 -0
- package/dist/internal/worker-engine-profile.d.ts +13 -0
- package/dist/internal/worker-engine-profile.d.ts.map +1 -0
- package/dist/kernel-worker-runtime.mjs +69 -0
- package/dist/kernel-worker-runtime.mjs.map +1 -0
- package/dist/load-game-errors.d.ts +131 -0
- package/dist/load-game-errors.d.ts.map +1 -0
- package/dist/load-game.d.ts +34 -0
- package/dist/load-game.d.ts.map +1 -0
- package/dist/plugin-factories.d.ts +3 -0
- package/dist/plugin-factories.d.ts.map +1 -0
- package/dist/tool-preview/bootstrap.d.ts +113 -0
- package/dist/tool-preview/bootstrap.d.ts.map +1 -0
- package/dist/tool-preview/evidence.d.ts +20 -0
- package/dist/tool-preview/evidence.d.ts.map +1 -0
- package/dist/tool-preview/framing.d.ts +13 -0
- package/dist/tool-preview/framing.d.ts.map +1 -0
- package/dist/tool-preview/recipe.d.ts +54 -0
- package/dist/tool-preview/recipe.d.ts.map +1 -0
- package/dist/types.d.ts +444 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +86 -0
- package/schema/execution-report.schema.json +168 -0
- package/src/__tests__/animation-asset-lookup.unit.test.ts +20 -0
- package/src/__tests__/app-rebuild.test.ts +31 -0
- package/src/__tests__/asset-registry-types.test-d.ts +16 -0
- package/src/__tests__/assets-runtime-assembly.integration.test.ts +68 -0
- package/src/__tests__/callback-deletion.unit.test.ts +14 -0
- package/src/__tests__/callback-inference.test-d.ts +32 -0
- package/src/__tests__/component-introspection.unit.test.ts +56 -0
- package/src/__tests__/consumer-migration.unit.test.ts +21 -0
- package/src/__tests__/create-app-features.test.ts +40 -0
- package/src/__tests__/create-app-listener-sync.test.ts +377 -0
- package/src/__tests__/create-app-lock-provider.test.ts +212 -0
- package/src/__tests__/create-app-plugin.test.ts +217 -0
- package/src/__tests__/create-app-stop.test.ts +617 -0
- package/src/__tests__/create-app-time.unit.test.ts +26 -0
- package/src/__tests__/create-app.test-d.ts +82 -0
- package/src/__tests__/create-app.test.ts +346 -0
- package/src/__tests__/create-renderer-error.integration.test.ts +24 -0
- package/src/__tests__/create-renderer-lifecycle.integration.test.ts +19 -0
- package/src/__tests__/docs-gate-green.unit.test.ts +61 -0
- package/src/__tests__/draw-source-headless-full-chain.test.ts +226 -0
- package/src/__tests__/draw-source-injected-world-update-visibility.test.ts +182 -0
- package/src/__tests__/draw-source-single-world-regression.test.ts +185 -0
- package/src/__tests__/ecs-import.unit.test.ts +40 -0
- package/src/__tests__/engine-worker-bootstrap-channel.unit.test.ts +74 -0
- package/src/__tests__/engine-worker-world-swap.unit.test.ts +76 -0
- package/src/__tests__/errors-pointer-lock-failed.test.ts +268 -0
- package/src/__tests__/execution-bootstrap-isolation.unit.test.ts +80 -0
- package/src/__tests__/execution-error-detail.test-d.ts +13 -0
- package/src/__tests__/execution-error-detail.unit.test.ts +35 -0
- package/src/__tests__/execution-frame-order.unit.test.ts +38 -0
- package/src/__tests__/execution-frame-protocol.unit.test.ts +61 -0
- package/src/__tests__/execution-input-sample.contract.test.ts +23 -0
- package/src/__tests__/execution-measurement.unit.test.ts +20 -0
- package/src/__tests__/execution-public-api.test-d.ts +10 -0
- package/src/__tests__/execution-public-api.unit.test.ts +9 -0
- package/src/__tests__/execution-rebuild.contract.test.ts +36 -0
- package/src/__tests__/execution-report.contract.test.ts +50 -0
- package/src/__tests__/execution-report.types.test-d.ts +16 -0
- package/src/__tests__/execution-schema-freshness.unit.test.ts +20 -0
- package/src/__tests__/execution-selector.unit.test.ts +72 -0
- package/src/__tests__/execution-shared-kernel.unit.test.ts +180 -0
- package/src/__tests__/execution-world-health-owner.test-d.ts +9 -0
- package/src/__tests__/fallback-camera.unit.test.ts +32 -0
- package/src/__tests__/frame-loop-time-elapsed.test.ts +115 -0
- package/src/__tests__/frame-loop-time.unit.test.ts +61 -0
- package/src/__tests__/frame-loop-world-array.test.ts +441 -0
- package/src/__tests__/kernel-worker-runtime.unit.test.ts +50 -0
- package/src/__tests__/migration-surface-inventory.unit.test.ts +13 -0
- package/src/__tests__/plugin-policy.integration.test.ts +15 -0
- package/src/__tests__/plugin-profiles.unit.test.ts +190 -0
- package/src/__tests__/profiler-default-off.test.ts +53 -0
- package/src/__tests__/profiler-frame-token.test.ts +248 -0
- package/src/__tests__/profiler-phase-catalog.test.ts +30 -0
- package/src/__tests__/profiler-phase-integration.test.ts +129 -0
- package/src/__tests__/profiler-remote-bridge.test.ts +31 -0
- package/src/__tests__/remote-integration.test.ts +224 -0
- package/src/__tests__/remote-serve-flag.test.ts +24 -0
- package/src/__tests__/rhi-capture-capability.test-d.ts +22 -0
- package/src/__tests__/rhi-capture-host.integration.test.ts +157 -0
- package/src/__tests__/rhi-capture.browser.test.ts +51 -0
- package/src/__tests__/surface-handoff.test.ts +92 -0
- package/src/__tests__/types-m2.test-d.ts +81 -0
- package/src/__tests__/visibility-remote.integration.test.ts +119 -0
- package/src/__tests__/worker-execution-stop.test.ts +116 -0
- package/src/animation-asset-lookup.ts +13 -0
- package/src/assets-runtime-assembly.ts +70 -0
- package/src/canvas-policy.ts +16 -0
- package/src/create-app.ts +1351 -0
- package/src/errors.ts +454 -0
- package/src/execution/attached-world-swap.ts +67 -0
- package/src/execution/bootstrap-entry.ts +122 -0
- package/src/execution/capabilities.ts +102 -0
- package/src/execution/control.ts +52 -0
- package/src/execution/engine-worker-runtime.ts +400 -0
- package/src/execution/engine-worker.ts +161 -0
- package/src/execution/host-controller.ts +454 -0
- package/src/execution/index.ts +43 -0
- package/src/execution/kernel-pool.ts +236 -0
- package/src/execution/kernel-worker-runtime.ts +103 -0
- package/src/execution/measurement.ts +39 -0
- package/src/execution/protocol.ts +131 -0
- package/src/execution/report.ts +66 -0
- package/src/execution/schema.ts +168 -0
- package/src/execution/selector.ts +98 -0
- package/src/execution/types.ts +146 -0
- package/src/fallback-camera.ts +20 -0
- package/src/game-context.ts +126 -0
- package/src/index.ts +176 -0
- package/src/input-map-plugin.ts +21 -0
- package/src/input-plugin.ts +26 -0
- package/src/internal/assembled-engine-profile.ts +22 -0
- package/src/internal/assets-world-plugin.ts +79 -0
- package/src/internal/browser-remote-bridge.ts +267 -0
- package/src/internal/browser-rhi-debug-runtime.ts +67 -0
- package/src/internal/component-introspection.ts +116 -0
- package/src/internal/ecs-import.ts +41 -0
- package/src/internal/engine-profile-common.ts +10 -0
- package/src/internal/error-fanout.ts +129 -0
- package/src/internal/frame-loop.ts +525 -0
- package/src/internal/input-attach.ts +104 -0
- package/src/internal/main-engine-profile.ts +80 -0
- package/src/internal/remote-serve-flag.ts +50 -0
- package/src/internal/remote-server-plugin.ts +16 -0
- package/src/internal/rhi-capture.ts +211 -0
- package/src/internal/rhi-debug-flag.ts +40 -0
- package/src/internal/worker-engine-profile.ts +47 -0
- package/src/load-game-errors.ts +211 -0
- package/src/load-game.ts +115 -0
- package/src/plugin-factories.ts +2 -0
- package/src/tool-preview/bootstrap.ts +848 -0
- package/src/tool-preview/evidence.ts +59 -0
- package/src/tool-preview/framing.ts +52 -0
- package/src/tool-preview/recipe.ts +137 -0
- package/src/types.ts +505 -0
- package/src/vite-env.d.ts +12 -0
|
@@ -0,0 +1,1351 @@
|
|
|
1
|
+
// @forgeax/engine-app -- createApp double-SSOT entry (M3 wiring).
|
|
2
|
+
//
|
|
3
|
+
// This file ships the final-shape signatures for both overload routes
|
|
4
|
+
// (canvas thin wrapper + assemble form per plan-strategy D-5):
|
|
5
|
+
//
|
|
6
|
+
// - createApp({ renderer, world, ... }) -- assemble form: returns an
|
|
7
|
+
// App with the supplied renderer / world wired through; the M2 rAF
|
|
8
|
+
// frame-loop + state machine + dt clamp drive start / stop / pause /
|
|
9
|
+
// resume. M3 lands listener-registry-backed onError + input-attach
|
|
10
|
+
// plumbing: when the host passes a pre-built InputBackend through
|
|
11
|
+
// args.input, the assemble form treats it as host-managed (no auto
|
|
12
|
+
// attach, no auto cleanup -- the host owns the lifetime).
|
|
13
|
+
//
|
|
14
|
+
// - createApp(canvas, opts?) -- canvas form (M3 partial):
|
|
15
|
+
// calls createRenderer(canvas, opts?), constructs a new World, and
|
|
16
|
+
// (when opts.input !== false) wires the auto input-attach helper so
|
|
17
|
+
// world.getResource('InputSnapshot') is populated each frame.
|
|
18
|
+
// Falls through to the assemble form for the rest of the wiring.
|
|
19
|
+
// Full canvas form (canvas-detached check, EngineEnvironmentError
|
|
20
|
+
// catch, console.error fallback) lands in M4 (plan-strategy section 7).
|
|
21
|
+
//
|
|
22
|
+
// 'tagName' in arg dispatch (per plan-strategy D-5): HTMLCanvasElement
|
|
23
|
+
// inherits .tagName from HTMLElement, so the property test cleanly
|
|
24
|
+
// separates the canvas argument from the AppAssembleArgs plain object.
|
|
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';
|
|
33
|
+
import type { AudioBackend } from '@forgeax/engine-audio';
|
|
34
|
+
import type { DebugDraw } from '@forgeax/engine-debug-draw';
|
|
35
|
+
import { createWorldContext, Update, World } from '@forgeax/engine-ecs';
|
|
36
|
+
import type { InputBackend } from '@forgeax/engine-input';
|
|
37
|
+
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';
|
|
40
|
+
import { RhiError } from '@forgeax/engine-rhi';
|
|
41
|
+
import type {
|
|
42
|
+
CaptureFrameOptions,
|
|
43
|
+
CreateShaderModuleFn,
|
|
44
|
+
RecorderAttachment,
|
|
45
|
+
} from '@forgeax/engine-rhi-debug';
|
|
46
|
+
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';
|
|
49
|
+
|
|
50
|
+
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
|
+
import type { AppErrorCode, AppErrorDetailFor } from './errors';
|
|
61
|
+
import { APP_ERROR_HINTS, APP_EXPECTED, AppError } from './errors';
|
|
62
|
+
import {
|
|
63
|
+
createExecutionReport,
|
|
64
|
+
type ExecutionControl,
|
|
65
|
+
executionBootstrapHostPlugin,
|
|
66
|
+
type PreparedExecutionBootstrap,
|
|
67
|
+
prepareBootstrapEntry,
|
|
68
|
+
probeExecutionCapabilities,
|
|
69
|
+
selectExecutionTier,
|
|
70
|
+
unavailableExecutionCapabilities,
|
|
71
|
+
} from './execution';
|
|
72
|
+
import { createLocalExecutionControl } from './execution/control';
|
|
73
|
+
import { createWorkerExecutionApp } from './execution/host-controller';
|
|
74
|
+
import { assembledEngineProfile } from './internal/assembled-engine-profile';
|
|
75
|
+
import { projectComponentIntrospection } from './internal/component-introspection';
|
|
76
|
+
import { ErrorFanoutRegistry } from './internal/error-fanout';
|
|
77
|
+
import { createFrameLoop } from './internal/frame-loop';
|
|
78
|
+
import { attachInputAuto } from './internal/input-attach';
|
|
79
|
+
import { mainEngineProfile } from './internal/main-engine-profile';
|
|
80
|
+
import { resolveRemoteServeFlag } from './internal/remote-serve-flag';
|
|
81
|
+
import { remoteServerPlugin } from './internal/remote-server-plugin';
|
|
82
|
+
import {
|
|
83
|
+
bindRhiCaptureFrameDriver,
|
|
84
|
+
createRhiCapture,
|
|
85
|
+
createRhiInstrumentation,
|
|
86
|
+
type RhiCapture,
|
|
87
|
+
} from './internal/rhi-capture';
|
|
88
|
+
import { resolveRhiDebugFlag } from './internal/rhi-debug-flag';
|
|
89
|
+
import type {
|
|
90
|
+
App,
|
|
91
|
+
AppAssembleArgs,
|
|
92
|
+
AppDispatchError,
|
|
93
|
+
AssembleAppError,
|
|
94
|
+
BundlerOptions,
|
|
95
|
+
CanvasAppError,
|
|
96
|
+
CanvasDrawingBufferSize,
|
|
97
|
+
CreateAppOptions,
|
|
98
|
+
ExecutionApp,
|
|
99
|
+
} from './types';
|
|
100
|
+
|
|
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
|
+
function makeAppError<C extends AppErrorCode>(
|
|
126
|
+
code: C,
|
|
127
|
+
expected: string,
|
|
128
|
+
hint: string,
|
|
129
|
+
detail: AppErrorDetailFor<C>,
|
|
130
|
+
): AppError {
|
|
131
|
+
return new AppError({ code, expected, hint, detail }) as AppError;
|
|
132
|
+
}
|
|
133
|
+
|
|
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 {
|
|
151
|
+
return {
|
|
152
|
+
name: 'canvas-aspect',
|
|
153
|
+
inject: ['world'],
|
|
154
|
+
apply(ctx) {
|
|
155
|
+
ctx.effect(() => {
|
|
156
|
+
const system = {
|
|
157
|
+
name: 'app-sync-camera-aspect',
|
|
158
|
+
queries: [],
|
|
159
|
+
fn: () => {
|
|
160
|
+
syncCanvasDrawingBuffer(canvas, maxCanvasPixelRatio);
|
|
161
|
+
syncCameraAspect(ctx.world, canvas.width, canvas.height);
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
ctx.world.addSystem(Update, system).unwrap();
|
|
165
|
+
return () => ctx.world.removeSystem(Update, system.name).unwrap();
|
|
166
|
+
}, 'app/canvas-aspect');
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function rhiDebugHostPlugin(dispose: () => void): Plugin {
|
|
172
|
+
return {
|
|
173
|
+
name: 'rhi-debug-host',
|
|
174
|
+
apply(ctx) {
|
|
175
|
+
ctx.effect(() => dispose, 'rhi-debug/host');
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* createApp(canvas, opts?, bundler?) -- canvas thin wrapper SSOT (per
|
|
182
|
+
* plan-strategy D-5). Resolves with Result.ok(app) on success; failure routes
|
|
183
|
+
* through AppError | RhiError | EngineEnvironmentError per requirements AC-01.
|
|
184
|
+
*
|
|
185
|
+
* feat-20260608-create-app-param-surface-trim / M2 / D-3: the third arg is
|
|
186
|
+
* `BundlerOptions` (importTransport + shaderManifestUrl) -- the SSOT for
|
|
187
|
+
* host-injected build-tool emit knowledge. M3 demos collapse the third-arg
|
|
188
|
+
* literal to `forgeaxBundlerAdapter()` exported by `virtual:forgeax/bundler`.
|
|
189
|
+
*
|
|
190
|
+
* M3 ships the path needed by AC-05 (auto input acquisition plus an ECS scan
|
|
191
|
+
* plugin). M4 finalises the canvas-detached guard +
|
|
192
|
+
* EngineEnvironmentError try/catch + onError default fallback.
|
|
193
|
+
*
|
|
194
|
+
* The host-engine contract defines the boundary between host (DOM, canvas, UI)
|
|
195
|
+
* and engine (renderer, world, frame loop). Only the `createApp(canvas)` path
|
|
196
|
+
* auto-wires the aspect-sync sidecar; the assemble form and bare
|
|
197
|
+
* `createRenderer` path do not.
|
|
198
|
+
*
|
|
199
|
+
* @see {@link https://github.com/Ubpa/forgeax-engine/blob/main/docs/how-to/2026-06-18-host-engine-contract.md | Host-engine contract SSOT}
|
|
200
|
+
*/
|
|
201
|
+
export function createApp(
|
|
202
|
+
canvas: HTMLCanvasElement,
|
|
203
|
+
opts: CreateAppOptions & { readonly execution: NonNullable<CreateAppOptions['execution']> },
|
|
204
|
+
bundler?: BundlerOptions,
|
|
205
|
+
): Promise<Result<ExecutionApp, CanvasAppError>>;
|
|
206
|
+
export function createApp(
|
|
207
|
+
canvas: HTMLCanvasElement,
|
|
208
|
+
opts?: CreateAppOptions,
|
|
209
|
+
bundler?: BundlerOptions,
|
|
210
|
+
): Promise<Result<App, CanvasAppError>>;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* createApp({ renderer, world, input?, schedule?, ... }) -- assemble-form
|
|
214
|
+
* SSOT (per plan-strategy D-5). Host already owns renderer / world; the
|
|
215
|
+
* returned App holds them by reference equality (per AC-02).
|
|
216
|
+
*
|
|
217
|
+
* M3 wires the rAF frame-loop + listener-registry-backed onError. When
|
|
218
|
+
* args.input is supplied, the assemble form treats it as host-managed:
|
|
219
|
+
* it is exposed verbatim as app.input, and the host is responsible for
|
|
220
|
+
* detaching it (no auto-cleanup on stop). The canvas form is the entry
|
|
221
|
+
* that engages the auto-attach helper (input-attach.ts).
|
|
222
|
+
*/
|
|
223
|
+
export function createApp(args: AppAssembleArgs): Promise<Result<App, AssembleAppError>>;
|
|
224
|
+
|
|
225
|
+
export function createApp(
|
|
226
|
+
arg: HTMLCanvasElement | AppAssembleArgs,
|
|
227
|
+
opts?: CreateAppOptions,
|
|
228
|
+
bundler?: BundlerOptions,
|
|
229
|
+
): Promise<Result<App | ExecutionApp, CanvasAppError>> {
|
|
230
|
+
if ('tagName' in arg) {
|
|
231
|
+
return createAppFromCanvas(arg, opts, bundler);
|
|
232
|
+
}
|
|
233
|
+
return createAppFromAssemble(arg);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function createAppFromCanvas(
|
|
237
|
+
canvas: HTMLCanvasElement,
|
|
238
|
+
opts: CreateAppOptions | undefined,
|
|
239
|
+
// feat-20260608-create-app-param-surface-trim / M2 / D-3: BundlerOptions is
|
|
240
|
+
// the host-injection SSOT (importTransport + shaderManifestUrl). Forwarded
|
|
241
|
+
// verbatim to createRenderer's third arg, so the engine reads
|
|
242
|
+
// shaderManifestUrl in its ShaderRegistry fallback (D-2 q5-A) and threads
|
|
243
|
+
// importTransport to AssetRegistry (AC-05 / R-4: keeps build-tool
|
|
244
|
+
// injection out of RendererOptions / CreateAppOptions).
|
|
245
|
+
bundler: BundlerOptions | undefined,
|
|
246
|
+
): Promise<Result<App | ExecutionApp, CanvasAppError>> {
|
|
247
|
+
// M4: 4-step thin wrapper per plan-strategy D-5.
|
|
248
|
+
//
|
|
249
|
+
// Step 1: canvas-detached fail-fast guard (AC-08). isConnected returns
|
|
250
|
+
// false when the canvas is not in the document tree, including freshly
|
|
251
|
+
// document.createElement('canvas') without appendChild. Returning
|
|
252
|
+
// Result.err here short-circuits before createRenderer fires off any
|
|
253
|
+
// 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
|
+
if (!canvas.isConnected) {
|
|
269
|
+
return err(
|
|
270
|
+
makeAppError(
|
|
271
|
+
'app-canvas-detached',
|
|
272
|
+
'canvas.isConnected === true at createApp(canvas) entry',
|
|
273
|
+
'append the canvas to the document tree before calling createApp; or use the assemble entry createApp({ renderer, world }) when the host already manages canvas lifetime',
|
|
274
|
+
{},
|
|
275
|
+
),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
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
|
+
// The DOM canvas starts with a 300x150 drawing buffer even when CSS lays it
|
|
289
|
+
// out at a different size. Set the physical buffer before the renderer
|
|
290
|
+
// configures its swap chain; the same helper runs before each frame so a CSS
|
|
291
|
+
// resize remains visible to both rendering and camera policy.
|
|
292
|
+
syncCanvasDrawingBuffer(canvas, maxCanvasPixelRatio);
|
|
293
|
+
|
|
294
|
+
if (opts?.execution !== undefined && normalizedExecutionBootstrap?.ok === true) {
|
|
295
|
+
const realmBoundOption = [
|
|
296
|
+
['features', opts.features],
|
|
297
|
+
['plugins', opts.plugins],
|
|
298
|
+
['rhi', opts.rhi],
|
|
299
|
+
['rawDeviceForContextConfigure', opts.rawDeviceForContextConfigure],
|
|
300
|
+
['drawSource', opts.drawSource],
|
|
301
|
+
['membershipTiming', opts.membershipTiming],
|
|
302
|
+
['assetCatalog', opts.assetCatalog],
|
|
303
|
+
['assetDecoders', opts.assetDecoders],
|
|
304
|
+
['bundler.importTransport', bundler?.importTransport],
|
|
305
|
+
].find(([, value]) => value !== undefined)?.[0];
|
|
306
|
+
if (realmBoundOption !== undefined) {
|
|
307
|
+
return err(
|
|
308
|
+
makeAppError(
|
|
309
|
+
'app-execution-bootstrap-failed',
|
|
310
|
+
APP_EXPECTED['app-execution-bootstrap-failed'],
|
|
311
|
+
APP_ERROR_HINTS['app-execution-bootstrap-failed'],
|
|
312
|
+
{
|
|
313
|
+
phase: 'prepare',
|
|
314
|
+
moduleUrl: normalizedExecutionBootstrap.value,
|
|
315
|
+
cause: new TypeError(
|
|
316
|
+
`${realmBoundOption} must be constructed by the execution bootstrap module`,
|
|
317
|
+
),
|
|
318
|
+
},
|
|
319
|
+
),
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let executionContext:
|
|
325
|
+
| {
|
|
326
|
+
readonly capabilities: import('./execution').ExecutionCapabilities;
|
|
327
|
+
readonly selection: import('./execution').ExecutionSelection;
|
|
328
|
+
}
|
|
329
|
+
| undefined;
|
|
330
|
+
let preparedExecutionBootstrap: PreparedExecutionBootstrap | undefined;
|
|
331
|
+
if (opts?.execution !== undefined && normalizedExecutionBootstrap?.ok === true) {
|
|
332
|
+
const executionOptions = opts.execution;
|
|
333
|
+
const capabilities = await probeExecutionCapabilities(canvas);
|
|
334
|
+
const selected = selectExecutionTier({
|
|
335
|
+
requestedTier: executionOptions.tier ?? 'auto',
|
|
336
|
+
capabilities,
|
|
337
|
+
sharedEvidencePassed: true,
|
|
338
|
+
});
|
|
339
|
+
if (!selected.ok) return err(selected.error);
|
|
340
|
+
executionContext = { capabilities, selection: selected.value };
|
|
341
|
+
if (selected.value.actualTier !== 'main-serial') {
|
|
342
|
+
return createWorkerExecutionApp({
|
|
343
|
+
canvas,
|
|
344
|
+
appOptions: opts,
|
|
345
|
+
syncCanvas: () => measureCanvasDrawingBuffer(canvas, maxCanvasPixelRatio),
|
|
346
|
+
...(bundler !== undefined ? { bundler } : {}),
|
|
347
|
+
capabilities,
|
|
348
|
+
selection: selected.value,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
const prepared = await prepareBootstrapEntry(
|
|
352
|
+
normalizedExecutionBootstrap.value,
|
|
353
|
+
executionOptions.bootstrapData,
|
|
354
|
+
);
|
|
355
|
+
if (!prepared.ok) return err(prepared.error);
|
|
356
|
+
preparedExecutionBootstrap = prepared.value;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Step 2: createRenderer try/catch -> Result.err(EngineEnvironmentError)
|
|
360
|
+
// (AC-01 / research section 2.2). createRenderer throws at
|
|
361
|
+
// createRenderer.ts:400 / :429 (rhi pack load failure /
|
|
362
|
+
// all WebGPU channels unavailable); we forward the original
|
|
363
|
+
// EngineEnvironmentError instance verbatim to preserve the
|
|
364
|
+
// .detail.webgpuError surface. RhiError instances raised mid-
|
|
365
|
+
// construction are also forwarded -- the AppError | RhiError leg of
|
|
366
|
+
// the union covers them. Any other unexpected throw is also
|
|
367
|
+
// forwarded to keep the contract honest (AI users walk the union
|
|
368
|
+
// discriminant rather than parse error.message strings).
|
|
369
|
+
// feat-20260608 / M2 / D-3: CreateAppOptions stops `extends RendererOptions`,
|
|
370
|
+
// so the two RHI escape hatches (rhi / rawDeviceForContextConfigure) are
|
|
371
|
+
// forwarded explicitly. Build a RendererOptions object out of just those
|
|
372
|
+
// fields when present; an empty {} keeps createRenderer on its default path.
|
|
373
|
+
const rendererOpts: import('@forgeax/engine-render').RendererOptions = {};
|
|
374
|
+
if (opts?.rhi !== undefined) {
|
|
375
|
+
Object.assign(rendererOpts, { rhi: opts.rhi });
|
|
376
|
+
}
|
|
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
|
+
if (opts?.profiler !== undefined) {
|
|
386
|
+
Object.assign(rendererOpts, { profiler: opts.profiler });
|
|
387
|
+
}
|
|
388
|
+
if (opts?.membershipTiming !== undefined) {
|
|
389
|
+
Object.assign(rendererOpts, { membershipTiming: opts.membershipTiming });
|
|
390
|
+
}
|
|
391
|
+
const rendererFeatures = preparedExecutionBootstrap?.features ?? opts?.features;
|
|
392
|
+
if (rendererFeatures !== undefined) {
|
|
393
|
+
Object.assign(rendererOpts, { features: rendererFeatures });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Optional RHI instrumentation is assembled once and forwarded through the
|
|
397
|
+
// typed Runtime -> Render seam. App keeps only the capture capability.
|
|
398
|
+
let rhiAttachment: RecorderAttachment | undefined;
|
|
399
|
+
let rhiCapture: RhiCapture | undefined;
|
|
400
|
+
let rhiDebugGlobal:
|
|
401
|
+
| {
|
|
402
|
+
captureFrame(options?: CaptureFrameOptions): ReturnType<RhiCapture['captureFrame']>;
|
|
403
|
+
}
|
|
404
|
+
| undefined;
|
|
405
|
+
const cleanupRhiDebugHost = (): void => {
|
|
406
|
+
const host = globalThis as { __forgeax?: typeof rhiDebugGlobal };
|
|
407
|
+
if (host.__forgeax === rhiDebugGlobal) delete host.__forgeax;
|
|
408
|
+
rhiDebugGlobal = undefined;
|
|
409
|
+
const attachment = rhiAttachment;
|
|
410
|
+
rhiAttachment = undefined;
|
|
411
|
+
if (attachment !== undefined) void attachment.dispose();
|
|
412
|
+
};
|
|
413
|
+
// Read FORGEAX_ENGINE_RHI_DEBUG from two sources (plan-strategy D-4):
|
|
414
|
+
// - browser: import.meta.env, statically replaced by the
|
|
415
|
+
// vite-plugin-rhi-debug `define` hook. The `typeof import.meta !==
|
|
416
|
+
// 'undefined'` prefix short-circuits under dawn-node, where import.meta
|
|
417
|
+
// itself can be undefined (C5).
|
|
418
|
+
// - dawn-node: globalThis.process.env (no vite define on the native path).
|
|
419
|
+
// resolveRhiDebugFlag (internal/rhi-debug-flag) is the SSOT for the `??`
|
|
420
|
+
// precedence; the source bags are computed inline here so the
|
|
421
|
+
// typeof-import.meta prefix stays at the call site. Keeps this file
|
|
422
|
+
// @types/node-free (engine-app ships ESM into both browser + dawn-node;
|
|
423
|
+
// same pattern as runtime/src/render-system-record.ts:isMeshSsboDevMode).
|
|
424
|
+
// Keep the Vite define key in a direct property expression. Wrapping
|
|
425
|
+
// `import.meta` in a structural cast hides the token from Vite's define
|
|
426
|
+
// pass, so production builds retain the debug-only dynamic chunks even when
|
|
427
|
+
// the Preview host explicitly sets the flag to `0`. Optional chaining keeps
|
|
428
|
+
// the package safe on the Dawn/Node path where `import.meta.env` is absent.
|
|
429
|
+
const importMetaEnv =
|
|
430
|
+
typeof import.meta !== 'undefined'
|
|
431
|
+
? { FORGEAX_ENGINE_RHI_DEBUG: import.meta.env?.FORGEAX_ENGINE_RHI_DEBUG }
|
|
432
|
+
: undefined;
|
|
433
|
+
// A browser production build must be able to erase the entire recorder
|
|
434
|
+
// branch. The `undefined` arm preserves Dawn/Node's process.env fallback;
|
|
435
|
+
// Vite replaces the direct property with `"0"` or `"1"`, making this outer
|
|
436
|
+
// guard a literal false/true before Rollup sees the debug-only imports.
|
|
437
|
+
const browserBuildRhiDebugFlag =
|
|
438
|
+
typeof import.meta !== 'undefined' ? import.meta.env?.FORGEAX_ENGINE_RHI_DEBUG : undefined;
|
|
439
|
+
const processEnv = (globalThis as { process?: { env?: { FORGEAX_ENGINE_RHI_DEBUG?: string } } })
|
|
440
|
+
.process?.env;
|
|
441
|
+
const rhiDebugFlag = resolveRhiDebugFlag(importMetaEnv, processEnv);
|
|
442
|
+
const nav: { gpu?: unknown } | undefined =
|
|
443
|
+
typeof globalThis !== 'undefined'
|
|
444
|
+
? (globalThis as { navigator?: { gpu?: unknown } }).navigator
|
|
445
|
+
: undefined;
|
|
446
|
+
const hasWebGPU = nav !== undefined && 'gpu' in nav && nav.gpu !== undefined;
|
|
447
|
+
if (
|
|
448
|
+
opts?.rhi === undefined &&
|
|
449
|
+
(browserBuildRhiDebugFlag === undefined || browserBuildRhiDebugFlag === '1') &&
|
|
450
|
+
rhiDebugFlag === '1'
|
|
451
|
+
) {
|
|
452
|
+
const backend = hasWebGPU
|
|
453
|
+
? 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');
|
|
458
|
+
if (pack.createShaderModule === undefined) {
|
|
459
|
+
throw new Error('RHI debug capture requires the backend shader-module entry');
|
|
460
|
+
}
|
|
461
|
+
const createShaderModule: CreateShaderModuleFn = pack.createShaderModule;
|
|
462
|
+
const attached = debugModule.attachRecorder({
|
|
463
|
+
rhi: pack.rhi,
|
|
464
|
+
createShaderModule,
|
|
465
|
+
});
|
|
466
|
+
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
|
+
|
|
472
|
+
Object.assign(rendererOpts, {
|
|
473
|
+
rhi: attachment.backend.rhi,
|
|
474
|
+
rhiInstrumentation: createRhiInstrumentation(attachment),
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
rhiDebugGlobal = {
|
|
478
|
+
captureFrame: (options) => capture.captureFrame(options),
|
|
479
|
+
};
|
|
480
|
+
(globalThis as { __forgeax?: typeof rhiDebugGlobal }).__forgeax = rhiDebugGlobal;
|
|
481
|
+
}
|
|
482
|
+
|
|
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
|
+
let renderer: Renderer;
|
|
490
|
+
try {
|
|
491
|
+
renderer = await createRenderer(canvas, rendererOpts, {
|
|
492
|
+
...bundler,
|
|
493
|
+
assetResolver: getAssetRegistryResolver(assetAssembly.registry),
|
|
494
|
+
});
|
|
495
|
+
} catch (e: unknown) {
|
|
496
|
+
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;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Step 2.4 decision: resolve whether the remote eval server should start
|
|
510
|
+
// (feat-20260629-inspector-two-layer-model M4 / w20). Dual-source gating
|
|
511
|
+
// mirrors the rhi-debug-flag pattern. The actual startServer call is
|
|
512
|
+
// deferred to after World creation (Step 3) because the server needs
|
|
513
|
+
// a live World reference.
|
|
514
|
+
const shouldStartRemote = resolveRemoteServeFlag(
|
|
515
|
+
typeof import.meta !== 'undefined'
|
|
516
|
+
? (import.meta as { env?: { DEV?: boolean } }).env?.DEV
|
|
517
|
+
: undefined,
|
|
518
|
+
(globalThis as { process?: { env?: { FORGEAX_ENGINE_REMOTE_SERVE?: string } } }).process?.env,
|
|
519
|
+
typeof window !== 'undefined',
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
let debugDraw: DebugDraw | undefined;
|
|
523
|
+
|
|
524
|
+
// Step 3: new World() -- the canvas form owns world lifetime, in
|
|
525
|
+
// contrast to the assemble form where the host owns it.
|
|
526
|
+
const world = new World(opts?.time !== undefined ? { time: opts.time } : {});
|
|
527
|
+
// Step 3.1 (M2 plugin-system-unify / D-4): app-layer side effects that the
|
|
528
|
+
// plugins consume via pre-injected world resources.
|
|
529
|
+
//
|
|
530
|
+
// Animation keeps durable clip GUIDs in graph payloads. The canvas default
|
|
531
|
+
// plugin receives the renderer-owned GUID catalogue as a lookup bridge; it
|
|
532
|
+
// still projects payloads into this World through the animation owner.
|
|
533
|
+
// transform + animation system registration lives in the plugins (default set).
|
|
534
|
+
|
|
535
|
+
// A normal canvas app owns its browser acquisition. An embedding host that
|
|
536
|
+
// shares this physical canvas with another world supplies its routed view via
|
|
537
|
+
// opts.input; createApp then consumes that one boundary instead of attaching a
|
|
538
|
+
// second listener set. The host owns the supplied backend's lifetime.
|
|
539
|
+
const inputHandle =
|
|
540
|
+
opts?.input === undefined
|
|
541
|
+
? attachInputAuto(canvas, {
|
|
542
|
+
...(opts?.uiRoot ? { uiRoot: opts.uiRoot } : {}),
|
|
543
|
+
...(opts?.pointerLockAllowed ? { pointerLockAllowed: opts.pointerLockAllowed } : {}),
|
|
544
|
+
...(opts?.virtualJoysticks ? { virtualJoysticks: opts.virtualJoysticks } : {}),
|
|
545
|
+
...(opts?.lockProvider ? { lockProvider: opts.lockProvider } : {}),
|
|
546
|
+
})
|
|
547
|
+
: undefined;
|
|
548
|
+
const inputBackend = opts?.input ?? inputHandle?.backend;
|
|
549
|
+
const userPlugins =
|
|
550
|
+
preparedExecutionBootstrap === undefined
|
|
551
|
+
? (opts?.plugins ?? [])
|
|
552
|
+
: [
|
|
553
|
+
executionBootstrapHostPlugin({
|
|
554
|
+
...(opts?.execution?.bootstrapPort === undefined
|
|
555
|
+
? {}
|
|
556
|
+
: { port: opts.execution.bootstrapPort }),
|
|
557
|
+
setPointerLockAllowed: (allowed) => inputBackend?.setPointerLockAllowed?.(allowed),
|
|
558
|
+
}),
|
|
559
|
+
...(preparedExecutionBootstrap.plugins ?? []),
|
|
560
|
+
];
|
|
561
|
+
let pluginContext: Context;
|
|
562
|
+
try {
|
|
563
|
+
pluginContext = await createWorldContext(
|
|
564
|
+
world,
|
|
565
|
+
mainEngineProfile({
|
|
566
|
+
renderer,
|
|
567
|
+
assetAssembly,
|
|
568
|
+
animationPayloads: createAnimationPayloadLookup(renderer.assets),
|
|
569
|
+
...(inputBackend === undefined ? {} : { input: inputBackend }),
|
|
570
|
+
...(inputHandle === undefined ? {} : { inputDispose: inputHandle.cleanup }),
|
|
571
|
+
...(opts?.inputMap === undefined ? {} : { inputMap: opts.inputMap }),
|
|
572
|
+
onDebugDrawReady: (value) => {
|
|
573
|
+
debugDraw = value;
|
|
574
|
+
},
|
|
575
|
+
extensions: [
|
|
576
|
+
rhiDebugHostPlugin(cleanupRhiDebugHost),
|
|
577
|
+
...userPlugins,
|
|
578
|
+
canvasAspectPlugin(canvas, maxCanvasPixelRatio),
|
|
579
|
+
],
|
|
580
|
+
}),
|
|
581
|
+
);
|
|
582
|
+
} catch (cause) {
|
|
583
|
+
cleanupRhiDebugHost();
|
|
584
|
+
assetAssembly.dispose();
|
|
585
|
+
assetAssembly.registry.dispose();
|
|
586
|
+
return err(
|
|
587
|
+
makeAppError(
|
|
588
|
+
'app-plugin-activation-failed',
|
|
589
|
+
APP_EXPECTED['app-plugin-activation-failed'],
|
|
590
|
+
APP_ERROR_HINTS['app-plugin-activation-failed'],
|
|
591
|
+
{ cause },
|
|
592
|
+
),
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
const audioBackend = pluginContext.audio;
|
|
596
|
+
|
|
597
|
+
const executionControl = createLocalExecutionControl(
|
|
598
|
+
executionContext === undefined
|
|
599
|
+
? {
|
|
600
|
+
...createExecutionReport('main-serial', unavailableExecutionCapabilities('not required')),
|
|
601
|
+
actualTier: 'main-serial',
|
|
602
|
+
selectionReason: 'explicit-request',
|
|
603
|
+
sharedEvidencePassed: true,
|
|
604
|
+
engine: { realm: 'host', health: 'idle' },
|
|
605
|
+
world: {
|
|
606
|
+
identity: world.identity,
|
|
607
|
+
health: world.execution.health,
|
|
608
|
+
partialWrite: false,
|
|
609
|
+
retryable: true,
|
|
610
|
+
},
|
|
611
|
+
}
|
|
612
|
+
: {
|
|
613
|
+
...createExecutionReport(
|
|
614
|
+
opts?.execution?.tier ?? 'auto',
|
|
615
|
+
executionContext.capabilities,
|
|
616
|
+
executionContext.selection,
|
|
617
|
+
),
|
|
618
|
+
engine: { realm: 'host', health: 'idle' },
|
|
619
|
+
world: {
|
|
620
|
+
identity: world.identity,
|
|
621
|
+
health: world.execution.health,
|
|
622
|
+
partialWrite: false,
|
|
623
|
+
retryable: true,
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
...(audioBackend === undefined ? {} : { audio: () => audioBackend.getState() }),
|
|
628
|
+
world: () => ({
|
|
629
|
+
identity: world.identity,
|
|
630
|
+
health: world.execution.health,
|
|
631
|
+
partialWrite: world.execution.fault?.partialWrite ?? false,
|
|
632
|
+
retryable: world.execution.fault?.retryable ?? true,
|
|
633
|
+
}),
|
|
634
|
+
},
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
// Step 3.3: Remote eval server auto-start (deferred from Step 2.4 so
|
|
638
|
+
// World is available). Dynamic import keeps @forgeax/engine-app free
|
|
639
|
+
// of static dep on @forgeax/engine-remote. Component reflection crosses
|
|
640
|
+
// this boundary as JSON-safe host data; app does not own any component.
|
|
641
|
+
const remoteHandle = shouldStartRemote
|
|
642
|
+
? await startRemoteServer(
|
|
643
|
+
world,
|
|
644
|
+
renderer,
|
|
645
|
+
assetAssembly.registry,
|
|
646
|
+
rhiCapture,
|
|
647
|
+
opts?.profiler,
|
|
648
|
+
executionControl,
|
|
649
|
+
)
|
|
650
|
+
: undefined;
|
|
651
|
+
if (remoteHandle !== undefined) await pluginContext.plugin(remoteServerPlugin(remoteHandle));
|
|
652
|
+
|
|
653
|
+
const buildArgs: BuildAppArgs = {
|
|
654
|
+
renderer,
|
|
655
|
+
assets: assetAssembly.registry,
|
|
656
|
+
world,
|
|
657
|
+
pluginContext,
|
|
658
|
+
executionControl,
|
|
659
|
+
...(inputBackend !== undefined ? { inputBackend } : {}),
|
|
660
|
+
...(inputHandle === undefined
|
|
661
|
+
? {}
|
|
662
|
+
: {
|
|
663
|
+
wireOnLockErrorDispatch: (dispatch: (err: AppError) => void) => {
|
|
664
|
+
inputHandle.setOnErrorDispatch(dispatch);
|
|
665
|
+
},
|
|
666
|
+
}),
|
|
667
|
+
};
|
|
668
|
+
if (audioBackend !== undefined) {
|
|
669
|
+
Object.assign(buildArgs, {
|
|
670
|
+
audioBackend,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
if (opts?.silenceUnhandledErrors !== undefined) {
|
|
674
|
+
Object.assign(buildArgs, { silenceUnhandledErrors: opts.silenceUnhandledErrors });
|
|
675
|
+
}
|
|
676
|
+
// M2 / D-3: canvas form forwards the host-supplied draw-source pull.
|
|
677
|
+
if (opts?.drawSource !== undefined) {
|
|
678
|
+
Object.assign(buildArgs, { drawSource: opts.drawSource });
|
|
679
|
+
}
|
|
680
|
+
if (opts?.profiler !== undefined) {
|
|
681
|
+
Object.assign(buildArgs, { profiler: opts.profiler });
|
|
682
|
+
}
|
|
683
|
+
if (rhiCapture !== undefined) {
|
|
684
|
+
Object.assign(buildArgs, {
|
|
685
|
+
rhiCapture,
|
|
686
|
+
...(rhiAttachment === undefined ? {} : { rhiAttachment }),
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
if (debugDraw !== undefined) {
|
|
690
|
+
Object.assign(buildArgs, { debugDraw });
|
|
691
|
+
}
|
|
692
|
+
if (remoteHandle !== undefined) {
|
|
693
|
+
Object.assign(buildArgs, { remoteHandle });
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// The aspect-sync sidecar belongs to the canvas path because it owns the DOM
|
|
697
|
+
// canvas. It runs as an Update system so it shares World scheduling semantics.
|
|
698
|
+
const built = await buildApp(buildArgs);
|
|
699
|
+
if (!built.ok) {
|
|
700
|
+
await pluginContext.fiber.dispose();
|
|
701
|
+
return built;
|
|
702
|
+
}
|
|
703
|
+
if (built.ok) {
|
|
704
|
+
// DEV-only browser remote bridge (remote-live). A browser cannot host the
|
|
705
|
+
// Node WS server that @forgeax/engine-remote/server needs, so the running
|
|
706
|
+
// engine would be unreachable from a CLI in a real dev browser. Instead the
|
|
707
|
+
// page dials OUT to a loopback relay and runs the ws-free eval core against
|
|
708
|
+
// the live world/renderer/assets/rhiCapture.
|
|
709
|
+
//
|
|
710
|
+
// OPT-IN via VITE_FORGEAX_ENGINE_BRIDGE=1 (set by scripts/dev-live.mjs), NOT
|
|
711
|
+
// on-by-default: a page that dials a relay which is not running makes the
|
|
712
|
+
// BROWSER itself log "WebSocket connection failed" to the console — noise a
|
|
713
|
+
// JS catch cannot suppress — which trips every zero-console-error browser
|
|
714
|
+
// smoke (collectathon / hello-*). So a plain `pnpm --filter <app> dev` (and
|
|
715
|
+
// CI) stays silent; only dev-live.mjs, which also launches the relay, turns
|
|
716
|
+
// it on. Production DCE's the whole block (import.meta.env.DEV === false).
|
|
717
|
+
// Additive: the Node startServer path above and app.remote are untouched.
|
|
718
|
+
if (
|
|
719
|
+
typeof import.meta !== 'undefined' &&
|
|
720
|
+
(import.meta as { env?: { DEV?: boolean; VITE_FORGEAX_ENGINE_BRIDGE?: string } }).env?.DEV ===
|
|
721
|
+
true &&
|
|
722
|
+
(import.meta as { env?: { VITE_FORGEAX_ENGINE_BRIDGE?: string } }).env
|
|
723
|
+
?.VITE_FORGEAX_ENGINE_BRIDGE === '1'
|
|
724
|
+
) {
|
|
725
|
+
const bridgePort =
|
|
726
|
+
(import.meta as { env?: { VITE_FORGEAX_ENGINE_BRIDGE_PORT?: string } }).env
|
|
727
|
+
?.VITE_FORGEAX_ENGINE_BRIDGE_PORT ?? '5733';
|
|
728
|
+
await pluginContext.plugin({
|
|
729
|
+
name: 'browser-remote-bridge',
|
|
730
|
+
inject: ['world', 'renderer', 'assetRegistry'],
|
|
731
|
+
async apply(ctx) {
|
|
732
|
+
if (ctx.renderer === undefined || ctx.assetRegistry === undefined) {
|
|
733
|
+
throw new Error('browser remote bridge requires renderer and assetRegistry services');
|
|
734
|
+
}
|
|
735
|
+
try {
|
|
736
|
+
const bridge = await import('./internal/browser-remote-bridge');
|
|
737
|
+
const teardown = await bridge.installBrowserRemoteBridge({
|
|
738
|
+
world: ctx.world,
|
|
739
|
+
renderer: ctx.renderer,
|
|
740
|
+
assets: ctx.assetRegistry,
|
|
741
|
+
runtimeModule: engineRuntimeModule,
|
|
742
|
+
...(rhiCapture !== undefined ? { rhiCapture } : {}),
|
|
743
|
+
...(opts?.profiler !== undefined ? { profiler: opts.profiler } : {}),
|
|
744
|
+
execution: built.value.execution,
|
|
745
|
+
port: bridgePort,
|
|
746
|
+
});
|
|
747
|
+
ctx.effect(() => teardown, 'remote/browser-bridge');
|
|
748
|
+
} catch {
|
|
749
|
+
// The optional live bridge does not make App creation fail.
|
|
750
|
+
}
|
|
751
|
+
},
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return built;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Synchronize a DOM canvas drawing buffer with its CSS size and device pixel
|
|
760
|
+
* ratio. Detached or zero-sized hosts are left untouched so a hidden canvas
|
|
761
|
+
* cannot be converted into a 1x1 render target accidentally.
|
|
762
|
+
*/
|
|
763
|
+
export function syncCanvasDrawingBuffer(
|
|
764
|
+
canvas: Pick<HTMLCanvasElement, 'clientWidth' | 'clientHeight' | 'width' | 'height'> & {
|
|
765
|
+
readonly style?: Pick<CSSStyleDeclaration, 'width' | 'height'>;
|
|
766
|
+
},
|
|
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 {
|
|
801
|
+
if (
|
|
802
|
+
!Number.isFinite(canvas.clientWidth) ||
|
|
803
|
+
!Number.isFinite(canvas.clientHeight) ||
|
|
804
|
+
canvas.clientWidth <= 0 ||
|
|
805
|
+
canvas.clientHeight <= 0
|
|
806
|
+
)
|
|
807
|
+
return undefined;
|
|
808
|
+
// An intrinsic canvas with no CSS dimensions already has an explicit
|
|
809
|
+
// drawing-buffer contract. `clientWidth` mirrors `width` for this shape;
|
|
810
|
+
// multiplying it by DPR would change the caller's requested pixel size and
|
|
811
|
+
// can feed that new width back as the next layout measurement. CSS-sized
|
|
812
|
+
// canvases take the DPR path below (including stylesheet-sized canvases
|
|
813
|
+
// whose layout width differs from the initial intrinsic width).
|
|
814
|
+
const hasExplicitCssSize =
|
|
815
|
+
(canvas.style?.width ?? '') !== '' || (canvas.style?.height ?? '') !== '';
|
|
816
|
+
if (
|
|
817
|
+
!hasExplicitCssSize &&
|
|
818
|
+
canvas.clientWidth === canvas.width &&
|
|
819
|
+
canvas.clientHeight === canvas.height
|
|
820
|
+
) {
|
|
821
|
+
return { width: canvas.width, height: canvas.height };
|
|
822
|
+
}
|
|
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);
|
|
830
|
+
const previous = syncedCanvasSizes.get(canvas);
|
|
831
|
+
const drawingBufferIsTheLayoutMeasurement =
|
|
832
|
+
previous !== undefined &&
|
|
833
|
+
canvas.width === previous.drawingWidth &&
|
|
834
|
+
canvas.height === previous.drawingHeight &&
|
|
835
|
+
canvas.clientWidth === previous.drawingWidth &&
|
|
836
|
+
canvas.clientHeight === previous.drawingHeight;
|
|
837
|
+
const cssWidth = drawingBufferIsTheLayoutMeasurement ? previous.cssWidth : canvas.clientWidth;
|
|
838
|
+
const cssHeight = drawingBufferIsTheLayoutMeasurement ? previous.cssHeight : canvas.clientHeight;
|
|
839
|
+
const width = Math.max(1, Math.round(cssWidth * dpr));
|
|
840
|
+
const height = Math.max(1, Math.round(cssHeight * dpr));
|
|
841
|
+
syncedCanvasSizes.set(canvas, {
|
|
842
|
+
cssWidth,
|
|
843
|
+
cssHeight,
|
|
844
|
+
drawingWidth: width,
|
|
845
|
+
drawingHeight: height,
|
|
846
|
+
});
|
|
847
|
+
return { width, height };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const syncedCanvasSizes = new WeakMap<
|
|
851
|
+
object,
|
|
852
|
+
{
|
|
853
|
+
readonly cssWidth: number;
|
|
854
|
+
readonly cssHeight: number;
|
|
855
|
+
readonly drawingWidth: number;
|
|
856
|
+
readonly drawingHeight: number;
|
|
857
|
+
}
|
|
858
|
+
>();
|
|
859
|
+
|
|
860
|
+
async function startRemoteServer(
|
|
861
|
+
world: World,
|
|
862
|
+
renderer: Renderer,
|
|
863
|
+
assets: AssetRegistry,
|
|
864
|
+
rhiCapture: RhiCapture | undefined,
|
|
865
|
+
profiler: import('@forgeax/engine-profiler').Profiler | undefined,
|
|
866
|
+
execution: ExecutionControl,
|
|
867
|
+
): Promise<{ readonly port: number; close(): Promise<void> } | undefined> {
|
|
868
|
+
try {
|
|
869
|
+
const remoteServerMod = (await import(
|
|
870
|
+
/* @vite-ignore */ '@forgeax/engine-remote/server'
|
|
871
|
+
)) as unknown as {
|
|
872
|
+
startServer: (opts: {
|
|
873
|
+
port: number;
|
|
874
|
+
host?: string;
|
|
875
|
+
world: unknown;
|
|
876
|
+
renderer?: unknown;
|
|
877
|
+
assets?: unknown;
|
|
878
|
+
rhiCapture?: unknown;
|
|
879
|
+
introspection?: readonly unknown[];
|
|
880
|
+
profiler?: unknown;
|
|
881
|
+
execution?: unknown;
|
|
882
|
+
}) => Promise<{
|
|
883
|
+
ok: boolean;
|
|
884
|
+
value?: { port: number; close(): Promise<void> };
|
|
885
|
+
}>;
|
|
886
|
+
};
|
|
887
|
+
const serverResult = await remoteServerMod.startServer({
|
|
888
|
+
port: 0,
|
|
889
|
+
host: '127.0.0.1',
|
|
890
|
+
world,
|
|
891
|
+
renderer,
|
|
892
|
+
assets,
|
|
893
|
+
introspection: projectComponentIntrospection(world.components.entries()),
|
|
894
|
+
...(rhiCapture !== undefined ? { rhiCapture } : {}),
|
|
895
|
+
...(profiler !== undefined ? { profiler } : {}),
|
|
896
|
+
execution,
|
|
897
|
+
});
|
|
898
|
+
if (serverResult.ok && serverResult.value !== undefined) {
|
|
899
|
+
return { port: serverResult.value.port, close: serverResult.value.close };
|
|
900
|
+
}
|
|
901
|
+
} catch (_error) {
|
|
902
|
+
// Dynamic import or server start failed; the app continues without remote.
|
|
903
|
+
}
|
|
904
|
+
return undefined;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async function createAppFromAssemble(
|
|
908
|
+
args: AppAssembleArgs,
|
|
909
|
+
): 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
|
+
let pluginContext: Context;
|
|
914
|
+
try {
|
|
915
|
+
pluginContext = await createWorldContext(
|
|
916
|
+
args.world,
|
|
917
|
+
assembledEngineProfile({
|
|
918
|
+
renderer: args.renderer,
|
|
919
|
+
assetAssembly,
|
|
920
|
+
extensions: args.plugins ?? [],
|
|
921
|
+
}),
|
|
922
|
+
);
|
|
923
|
+
} catch (cause) {
|
|
924
|
+
assetAssembly.dispose();
|
|
925
|
+
assetAssembly.registry.dispose();
|
|
926
|
+
return err(
|
|
927
|
+
makeAppError(
|
|
928
|
+
'app-plugin-activation-failed',
|
|
929
|
+
APP_EXPECTED['app-plugin-activation-failed'],
|
|
930
|
+
APP_ERROR_HINTS['app-plugin-activation-failed'],
|
|
931
|
+
{ cause },
|
|
932
|
+
),
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const shouldStartRemote = resolveRemoteServeFlag(
|
|
937
|
+
undefined,
|
|
938
|
+
(globalThis as { process?: { env?: { FORGEAX_ENGINE_REMOTE_SERVE?: string } } }).process?.env,
|
|
939
|
+
);
|
|
940
|
+
const assembledAudioBackend = pluginContext.audio;
|
|
941
|
+
const executionControl = createLocalExecutionControl(
|
|
942
|
+
{
|
|
943
|
+
...createExecutionReport('main-serial', unavailableExecutionCapabilities('not required')),
|
|
944
|
+
actualTier: 'main-serial',
|
|
945
|
+
selectionReason: 'explicit-request',
|
|
946
|
+
sharedEvidencePassed: true,
|
|
947
|
+
engine: { realm: 'host', health: 'idle' },
|
|
948
|
+
world: {
|
|
949
|
+
identity: args.world.identity,
|
|
950
|
+
health: args.world.execution.health,
|
|
951
|
+
partialWrite: false,
|
|
952
|
+
retryable: true,
|
|
953
|
+
},
|
|
954
|
+
},
|
|
955
|
+
{
|
|
956
|
+
...(assembledAudioBackend === undefined
|
|
957
|
+
? {}
|
|
958
|
+
: { audio: () => assembledAudioBackend.getState() }),
|
|
959
|
+
world: () => ({
|
|
960
|
+
identity: args.world.identity,
|
|
961
|
+
health: args.world.execution.health,
|
|
962
|
+
partialWrite: args.world.execution.fault?.partialWrite ?? false,
|
|
963
|
+
retryable: args.world.execution.fault?.retryable ?? true,
|
|
964
|
+
}),
|
|
965
|
+
},
|
|
966
|
+
);
|
|
967
|
+
const remoteHandle = shouldStartRemote
|
|
968
|
+
? await startRemoteServer(
|
|
969
|
+
args.world,
|
|
970
|
+
args.renderer,
|
|
971
|
+
assetAssembly.registry,
|
|
972
|
+
undefined,
|
|
973
|
+
args.profiler,
|
|
974
|
+
executionControl,
|
|
975
|
+
)
|
|
976
|
+
: undefined;
|
|
977
|
+
if (remoteHandle !== undefined) await pluginContext.plugin(remoteServerPlugin(remoteHandle));
|
|
978
|
+
|
|
979
|
+
const buildArgs: BuildAppArgs = {
|
|
980
|
+
renderer: args.renderer,
|
|
981
|
+
assets: assetAssembly.registry,
|
|
982
|
+
world: args.world,
|
|
983
|
+
pluginContext,
|
|
984
|
+
executionControl,
|
|
985
|
+
...(remoteHandle !== undefined ? { remoteHandle } : {}),
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
const assembledInputBackend = pluginContext.input;
|
|
989
|
+
if (assembledInputBackend !== undefined) {
|
|
990
|
+
Object.assign(buildArgs, {
|
|
991
|
+
inputBackend: assembledInputBackend,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
if (assembledAudioBackend !== undefined) {
|
|
995
|
+
Object.assign(buildArgs, {
|
|
996
|
+
audioBackend: assembledAudioBackend,
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
if (args.silenceUnhandledErrors !== undefined) {
|
|
1000
|
+
Object.assign(buildArgs, { silenceUnhandledErrors: args.silenceUnhandledErrors });
|
|
1001
|
+
}
|
|
1002
|
+
// M2 / D-3: assemble form forwards the host-supplied draw-source pull.
|
|
1003
|
+
if (args.drawSource !== undefined) {
|
|
1004
|
+
Object.assign(buildArgs, { drawSource: args.drawSource });
|
|
1005
|
+
}
|
|
1006
|
+
if (args.profiler !== undefined) {
|
|
1007
|
+
Object.assign(buildArgs, { profiler: args.profiler });
|
|
1008
|
+
}
|
|
1009
|
+
const built = await buildApp(buildArgs);
|
|
1010
|
+
if (!built.ok) await pluginContext.fiber.dispose();
|
|
1011
|
+
return built;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
interface BuildAppArgs {
|
|
1015
|
+
readonly renderer: Renderer;
|
|
1016
|
+
readonly assets: AssetRegistry;
|
|
1017
|
+
readonly world: World;
|
|
1018
|
+
readonly pluginContext: Context;
|
|
1019
|
+
readonly inputBackend?: InputBackend;
|
|
1020
|
+
/**
|
|
1021
|
+
* M2 D-4: callback that buildApp calls after creating the ErrorFanoutRegistry
|
|
1022
|
+
* dispatch function. The input handle's onLockError callback needs the dispatch
|
|
1023
|
+
* function to fan out 'app-pointer-lock-failed' errors, but the dispatch function
|
|
1024
|
+
* is created inside buildApp (after the input handle is already constructed).
|
|
1025
|
+
* This callback bridges the gap.
|
|
1026
|
+
*/
|
|
1027
|
+
readonly wireOnLockErrorDispatch?: (dispatch: (err: AppError) => void) => void;
|
|
1028
|
+
readonly audioBackend?: AudioBackend;
|
|
1029
|
+
readonly silenceUnhandledErrors?: boolean;
|
|
1030
|
+
/** M4: host-owned RHI capture capability attached to the app surface. */
|
|
1031
|
+
readonly rhiCapture?: RhiCapture;
|
|
1032
|
+
/** Internal attachment used to bind capture to this App's frame authority. */
|
|
1033
|
+
readonly rhiAttachment?: RecorderAttachment;
|
|
1034
|
+
/** feat-20260615 debug-draw M5: DebugDraw instance created by createDebugDrawOnReady. */
|
|
1035
|
+
readonly debugDraw?: DebugDraw;
|
|
1036
|
+
/** feat-20260629 M4 / w20: remote eval server handle from createAppFromCanvas. */
|
|
1037
|
+
readonly remoteHandle?: { readonly port: number; close(): Promise<void> };
|
|
1038
|
+
/**
|
|
1039
|
+
* feat-20260709-editor-world-partition-editorworld-super-composite / M2 / D-3:
|
|
1040
|
+
* per-frame draw-source pull forwarded verbatim into the frame-loop. Absent =>
|
|
1041
|
+
* the allocation-free primary-World path. Both createApp forms (canvas + assemble) forward
|
|
1042
|
+
* it from their respective options object.
|
|
1043
|
+
*/
|
|
1044
|
+
readonly drawSource?: () =>
|
|
1045
|
+
| {
|
|
1046
|
+
worlds: readonly import('@forgeax/engine-ecs').World[];
|
|
1047
|
+
cameraOwner: number;
|
|
1048
|
+
resourceOwner: number;
|
|
1049
|
+
}
|
|
1050
|
+
| undefined;
|
|
1051
|
+
readonly profiler?: import('@forgeax/engine-profiler').Profiler;
|
|
1052
|
+
readonly executionControl?: import('./execution/control').LocalExecutionControl;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Internal builder shared by both overloads. Wires the frame-loop +
|
|
1057
|
+
* listener-registry onError and returns the App handle. Effectful ownership
|
|
1058
|
+
* has already moved into pluginContext before this boundary runs.
|
|
1059
|
+
*/
|
|
1060
|
+
async function buildApp(args: BuildAppArgs): Promise<Result<App, AppError | RhiError>> {
|
|
1061
|
+
const {
|
|
1062
|
+
renderer,
|
|
1063
|
+
assets,
|
|
1064
|
+
world,
|
|
1065
|
+
pluginContext,
|
|
1066
|
+
inputBackend,
|
|
1067
|
+
audioBackend,
|
|
1068
|
+
silenceUnhandledErrors,
|
|
1069
|
+
rhiCapture,
|
|
1070
|
+
rhiAttachment,
|
|
1071
|
+
debugDraw,
|
|
1072
|
+
remoteHandle,
|
|
1073
|
+
profiler,
|
|
1074
|
+
} = args;
|
|
1075
|
+
|
|
1076
|
+
// Physics is a Context service; the App exposes the active provider without
|
|
1077
|
+
// storing a second mutable slot.
|
|
1078
|
+
function readPhysicsWorld():
|
|
1079
|
+
| import('@forgeax/engine-physics').PhysicsWorld
|
|
1080
|
+
| import('@forgeax/engine-physics').PhysicsWorld2D
|
|
1081
|
+
| undefined {
|
|
1082
|
+
return pluginContext.physics;
|
|
1083
|
+
}
|
|
1084
|
+
// M4 (w11): listener registry replaces the M3 inline Set so console.error
|
|
1085
|
+
// fallback + duplicate-add no-op + unsubscribe handle behaviour matches
|
|
1086
|
+
// packages/runtime/src/createRenderer.ts:532-566 LostListenerRegistry
|
|
1087
|
+
// (plan-strategy D-9). silenceUnhandledErrors threads through verbatim.
|
|
1088
|
+
const fanout = new ErrorFanoutRegistry(
|
|
1089
|
+
silenceUnhandledErrors !== undefined ? { silenceUnhandledErrors } : {},
|
|
1090
|
+
);
|
|
1091
|
+
const execution =
|
|
1092
|
+
args.executionControl ??
|
|
1093
|
+
createLocalExecutionControl(
|
|
1094
|
+
{
|
|
1095
|
+
...createExecutionReport('main-serial', unavailableExecutionCapabilities('not required')),
|
|
1096
|
+
actualTier: 'main-serial',
|
|
1097
|
+
selectionReason: 'explicit-request',
|
|
1098
|
+
sharedEvidencePassed: true,
|
|
1099
|
+
engine: { realm: 'host', health: 'idle' },
|
|
1100
|
+
world: {
|
|
1101
|
+
identity: world.identity,
|
|
1102
|
+
health: world.execution.health,
|
|
1103
|
+
partialWrite: world.execution.fault?.partialWrite ?? false,
|
|
1104
|
+
retryable: world.execution.fault?.retryable ?? true,
|
|
1105
|
+
},
|
|
1106
|
+
},
|
|
1107
|
+
{
|
|
1108
|
+
...(audioBackend === undefined ? {} : { audio: () => audioBackend.getState() }),
|
|
1109
|
+
world: () => ({
|
|
1110
|
+
identity: world.identity,
|
|
1111
|
+
health: world.execution.health,
|
|
1112
|
+
partialWrite: world.execution.fault?.partialWrite ?? false,
|
|
1113
|
+
retryable: world.execution.fault?.retryable ?? true,
|
|
1114
|
+
}),
|
|
1115
|
+
},
|
|
1116
|
+
);
|
|
1117
|
+
|
|
1118
|
+
function dispatch(e: AppDispatchError): void {
|
|
1119
|
+
fanout.fire(e);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// M2 D-4: wire the input handle's onLockError callback to the error fan-out.
|
|
1123
|
+
// The dispatch function is created here; the input handle was created earlier
|
|
1124
|
+
// in createAppFromCanvas and passed to buildApp with a wireOnLockErrorDispatch
|
|
1125
|
+
// callback that calls setOnErrorDispatch on the handle.
|
|
1126
|
+
if (args.wireOnLockErrorDispatch) {
|
|
1127
|
+
args.wireOnLockErrorDispatch(dispatch);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const loopOpts: Parameters<typeof createFrameLoop>[0] = {
|
|
1131
|
+
world,
|
|
1132
|
+
renderer,
|
|
1133
|
+
onError: dispatch,
|
|
1134
|
+
};
|
|
1135
|
+
// M2 / D-3: forward the optional draw-source pull into the frame-loop. Absent
|
|
1136
|
+
// => the loop keeps the allocation-free primary-World draw path.
|
|
1137
|
+
if (args.drawSource !== undefined) {
|
|
1138
|
+
Object.assign(loopOpts, { drawSource: args.drawSource });
|
|
1139
|
+
}
|
|
1140
|
+
if (profiler !== undefined) {
|
|
1141
|
+
Object.assign(loopOpts, { profiler });
|
|
1142
|
+
}
|
|
1143
|
+
const loop = createFrameLoop(loopOpts);
|
|
1144
|
+
|
|
1145
|
+
if (rhiCapture !== undefined && rhiAttachment !== undefined) {
|
|
1146
|
+
bindRhiCaptureFrameDriver(rhiCapture, {
|
|
1147
|
+
getState: () => loop.getState(),
|
|
1148
|
+
pause: () => {
|
|
1149
|
+
const result = loop.pause();
|
|
1150
|
+
if (result.ok) execution.setEngineHealth('idle');
|
|
1151
|
+
return result;
|
|
1152
|
+
},
|
|
1153
|
+
resume: () => {
|
|
1154
|
+
const result = loop.resume();
|
|
1155
|
+
if (result.ok) execution.setEngineHealth('running');
|
|
1156
|
+
return result;
|
|
1157
|
+
},
|
|
1158
|
+
stepFrame: (deltaSeconds) => loop.stepFrame(deltaSeconds),
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
let lastError: AppDispatchError | undefined;
|
|
1163
|
+
|
|
1164
|
+
// M4 (w13) device-lost internal subscription. R-1 timing contract:
|
|
1165
|
+
// app.start() arms the rAF handle BEFORE this listener subscribes, so
|
|
1166
|
+
// a synchronous late-attach replay of a persisted device-lost event
|
|
1167
|
+
// (LostListenerRegistry replay -- runtime/src/renderer.ts:337-345)
|
|
1168
|
+
// hits a frame-loop with a real rAF handle to cancel (M2 setStopped
|
|
1169
|
+
// tolerates pendingFrameId === 0 as a no-op so even pre-rAF replays
|
|
1170
|
+
// do not NPE). The subscription remains active across pause / resume;
|
|
1171
|
+
// unsubscribe runs only on stop / disposal (charter P3:
|
|
1172
|
+
// device-lost is a terminal lifecycle signal, not a transient blip).
|
|
1173
|
+
let rendererUnsubscribe: (() => void) | undefined;
|
|
1174
|
+
let resumeAfterSurfaceRestore = false;
|
|
1175
|
+
|
|
1176
|
+
function subscribeRendererErrors(): void {
|
|
1177
|
+
if (rendererUnsubscribe !== undefined) {
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
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).
|
|
1206
|
+
dispatch(e);
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function unsubscribeRendererErrors(): void {
|
|
1211
|
+
if (rendererUnsubscribe !== undefined) {
|
|
1212
|
+
rendererUnsubscribe();
|
|
1213
|
+
rendererUnsubscribe = undefined;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const stub: App = {
|
|
1218
|
+
renderer,
|
|
1219
|
+
assets,
|
|
1220
|
+
world,
|
|
1221
|
+
execution,
|
|
1222
|
+
async releaseSurfacePreserveWorld(): Promise<Result<void, RhiError>> {
|
|
1223
|
+
if (loop.getState() === 'running') {
|
|
1224
|
+
const paused = loop.pause();
|
|
1225
|
+
if (!paused.ok) {
|
|
1226
|
+
return err(
|
|
1227
|
+
new RhiError({
|
|
1228
|
+
code: 'rhi-not-available',
|
|
1229
|
+
expected: 'running App pauses before releasing its presentation surface',
|
|
1230
|
+
hint: paused.error.hint,
|
|
1231
|
+
}),
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
resumeAfterSurfaceRestore = true;
|
|
1235
|
+
execution.setEngineHealth('idle');
|
|
1236
|
+
}
|
|
1237
|
+
// A surface handoff can be requested by an Update system while the
|
|
1238
|
+
// current frame is still in progress. Let that frame reach renderer.draw
|
|
1239
|
+
// before unconfiguring the surface; pause() only cancels the next rAF.
|
|
1240
|
+
await Promise.resolve();
|
|
1241
|
+
const released = renderer.releaseSurface();
|
|
1242
|
+
if (!released.ok && resumeAfterSurfaceRestore) {
|
|
1243
|
+
loop.resume();
|
|
1244
|
+
resumeAfterSurfaceRestore = false;
|
|
1245
|
+
execution.setEngineHealth('running');
|
|
1246
|
+
}
|
|
1247
|
+
return released.ok ? ok(undefined) : err(released.error);
|
|
1248
|
+
},
|
|
1249
|
+
async restoreSurface(): Promise<Result<void, RhiError>> {
|
|
1250
|
+
const restored = renderer.restoreSurface();
|
|
1251
|
+
if (!restored.ok) return err(restored.error);
|
|
1252
|
+
if (resumeAfterSurfaceRestore) {
|
|
1253
|
+
const resumed = loop.resume();
|
|
1254
|
+
if (!resumed.ok) {
|
|
1255
|
+
return err(
|
|
1256
|
+
new RhiError({
|
|
1257
|
+
code: 'rhi-not-available',
|
|
1258
|
+
expected: 'paused App resumes after restoring its presentation surface',
|
|
1259
|
+
hint: resumed.error.hint,
|
|
1260
|
+
}),
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
resumeAfterSurfaceRestore = false;
|
|
1264
|
+
execution.setEngineHealth('running');
|
|
1265
|
+
}
|
|
1266
|
+
return ok(undefined);
|
|
1267
|
+
},
|
|
1268
|
+
pluginContext,
|
|
1269
|
+
...(inputBackend !== undefined ? { input: inputBackend } : {}),
|
|
1270
|
+
...(audioBackend !== undefined ? { audio: audioBackend } : {}),
|
|
1271
|
+
get physics():
|
|
1272
|
+
| import('@forgeax/engine-physics').PhysicsWorld
|
|
1273
|
+
| import('@forgeax/engine-physics').PhysicsWorld2D
|
|
1274
|
+
| undefined {
|
|
1275
|
+
return readPhysicsWorld();
|
|
1276
|
+
},
|
|
1277
|
+
start(): Result<void, AppError> {
|
|
1278
|
+
// R-1: arm the rAF handle FIRST (loop.start schedules raf(tick))
|
|
1279
|
+
// and only THEN subscribe to renderer.onError. If the renderer
|
|
1280
|
+
// late-attach replays a persisted device-lost event during the
|
|
1281
|
+
// subscribe call, the loop is already armed and can retain the error.
|
|
1282
|
+
const r = loop.start();
|
|
1283
|
+
if (r.ok) {
|
|
1284
|
+
execution.setEngineHealth('running');
|
|
1285
|
+
subscribeRendererErrors();
|
|
1286
|
+
}
|
|
1287
|
+
return r;
|
|
1288
|
+
},
|
|
1289
|
+
stop(): Result<void, AppError> {
|
|
1290
|
+
const r = loop.stop();
|
|
1291
|
+
if (r.ok) execution.setEngineHealth('stopped');
|
|
1292
|
+
unsubscribeRendererErrors();
|
|
1293
|
+
return r;
|
|
1294
|
+
},
|
|
1295
|
+
async dispose(): Promise<Result<void, AppError>> {
|
|
1296
|
+
const state = loop.getState();
|
|
1297
|
+
if (state === 'running' || state === 'paused') loop.stop();
|
|
1298
|
+
else if (state !== 'stopped') loop.setStopped();
|
|
1299
|
+
await pluginContext.fiber.dispose();
|
|
1300
|
+
unsubscribeRendererErrors();
|
|
1301
|
+
execution.setEngineHealth('stopped');
|
|
1302
|
+
return ok(undefined);
|
|
1303
|
+
},
|
|
1304
|
+
pause(): Result<void, AppError> {
|
|
1305
|
+
const r = loop.pause();
|
|
1306
|
+
if (r.ok) execution.setEngineHealth('idle');
|
|
1307
|
+
return r;
|
|
1308
|
+
},
|
|
1309
|
+
resume(): Result<void, AppError> {
|
|
1310
|
+
const r = loop.resume();
|
|
1311
|
+
if (r.ok) execution.setEngineHealth('running');
|
|
1312
|
+
return r;
|
|
1313
|
+
},
|
|
1314
|
+
stepFrame(deltaSeconds): Result<void, AppDispatchError> {
|
|
1315
|
+
return loop.stepFrame(deltaSeconds);
|
|
1316
|
+
},
|
|
1317
|
+
onError(cb: (e: AppDispatchError) => void): () => void {
|
|
1318
|
+
return fanout.add(cb);
|
|
1319
|
+
},
|
|
1320
|
+
setDrawSource(drawSource): void {
|
|
1321
|
+
loop.setDrawSource(drawSource);
|
|
1322
|
+
},
|
|
1323
|
+
/**
|
|
1324
|
+
* Most recent device-loss error retained for host self-inspection.
|
|
1325
|
+
*/
|
|
1326
|
+
get lastError(): AppDispatchError | undefined {
|
|
1327
|
+
return lastError;
|
|
1328
|
+
},
|
|
1329
|
+
...(rhiCapture !== undefined ? { rhiCapture } : {}),
|
|
1330
|
+
...(debugDraw !== undefined ? { debugDraw } : {}),
|
|
1331
|
+
...(remoteHandle !== undefined ? { remote: remoteHandle } : {}),
|
|
1332
|
+
};
|
|
1333
|
+
|
|
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
|
+
return ok(stub);
|
|
1351
|
+
}
|