@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.
Files changed (354) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +418 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/animation-asset-lookup.unit.test.d.ts +2 -0
  5. package/dist/__tests__/animation-asset-lookup.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/app-rebuild.test.d.ts +2 -0
  7. package/dist/__tests__/app-rebuild.test.d.ts.map +1 -0
  8. package/dist/__tests__/asset-registry-types.test-d.d.ts +2 -0
  9. package/dist/__tests__/asset-registry-types.test-d.d.ts.map +1 -0
  10. package/dist/__tests__/assets-runtime-assembly.integration.test.d.ts +2 -0
  11. package/dist/__tests__/assets-runtime-assembly.integration.test.d.ts.map +1 -0
  12. package/dist/__tests__/callback-deletion.unit.test.d.ts +2 -0
  13. package/dist/__tests__/callback-deletion.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/callback-inference.test-d.d.ts +2 -0
  15. package/dist/__tests__/callback-inference.test-d.d.ts.map +1 -0
  16. package/dist/__tests__/component-introspection.unit.test.d.ts +2 -0
  17. package/dist/__tests__/component-introspection.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/consumer-migration.unit.test.d.ts +2 -0
  19. package/dist/__tests__/consumer-migration.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/create-app-features.test.d.ts +2 -0
  21. package/dist/__tests__/create-app-features.test.d.ts.map +1 -0
  22. package/dist/__tests__/create-app-listener-sync.test.d.ts +2 -0
  23. package/dist/__tests__/create-app-listener-sync.test.d.ts.map +1 -0
  24. package/dist/__tests__/create-app-lock-provider.test.d.ts +2 -0
  25. package/dist/__tests__/create-app-lock-provider.test.d.ts.map +1 -0
  26. package/dist/__tests__/create-app-plugin.test.d.ts +2 -0
  27. package/dist/__tests__/create-app-plugin.test.d.ts.map +1 -0
  28. package/dist/__tests__/create-app-stop.test.d.ts +2 -0
  29. package/dist/__tests__/create-app-stop.test.d.ts.map +1 -0
  30. package/dist/__tests__/create-app-time.unit.test.d.ts +2 -0
  31. package/dist/__tests__/create-app-time.unit.test.d.ts.map +1 -0
  32. package/dist/__tests__/create-app.test-d.d.ts +2 -0
  33. package/dist/__tests__/create-app.test-d.d.ts.map +1 -0
  34. package/dist/__tests__/create-app.test.d.ts +2 -0
  35. package/dist/__tests__/create-app.test.d.ts.map +1 -0
  36. package/dist/__tests__/create-renderer-error.integration.test.d.ts +2 -0
  37. package/dist/__tests__/create-renderer-error.integration.test.d.ts.map +1 -0
  38. package/dist/__tests__/create-renderer-lifecycle.integration.test.d.ts +2 -0
  39. package/dist/__tests__/create-renderer-lifecycle.integration.test.d.ts.map +1 -0
  40. package/dist/__tests__/docs-gate-green.unit.test.d.ts +2 -0
  41. package/dist/__tests__/docs-gate-green.unit.test.d.ts.map +1 -0
  42. package/dist/__tests__/draw-source-headless-full-chain.test.d.ts +2 -0
  43. package/dist/__tests__/draw-source-headless-full-chain.test.d.ts.map +1 -0
  44. package/dist/__tests__/draw-source-injected-world-update-visibility.test.d.ts +2 -0
  45. package/dist/__tests__/draw-source-injected-world-update-visibility.test.d.ts.map +1 -0
  46. package/dist/__tests__/draw-source-single-world-regression.test.d.ts +2 -0
  47. package/dist/__tests__/draw-source-single-world-regression.test.d.ts.map +1 -0
  48. package/dist/__tests__/ecs-import.unit.test.d.ts +2 -0
  49. package/dist/__tests__/ecs-import.unit.test.d.ts.map +1 -0
  50. package/dist/__tests__/engine-worker-bootstrap-channel.unit.test.d.ts +2 -0
  51. package/dist/__tests__/engine-worker-bootstrap-channel.unit.test.d.ts.map +1 -0
  52. package/dist/__tests__/engine-worker-world-swap.unit.test.d.ts +2 -0
  53. package/dist/__tests__/engine-worker-world-swap.unit.test.d.ts.map +1 -0
  54. package/dist/__tests__/errors-pointer-lock-failed.test.d.ts +2 -0
  55. package/dist/__tests__/errors-pointer-lock-failed.test.d.ts.map +1 -0
  56. package/dist/__tests__/execution-bootstrap-isolation.unit.test.d.ts +2 -0
  57. package/dist/__tests__/execution-bootstrap-isolation.unit.test.d.ts.map +1 -0
  58. package/dist/__tests__/execution-error-detail.test-d.d.ts +2 -0
  59. package/dist/__tests__/execution-error-detail.test-d.d.ts.map +1 -0
  60. package/dist/__tests__/execution-error-detail.unit.test.d.ts +2 -0
  61. package/dist/__tests__/execution-error-detail.unit.test.d.ts.map +1 -0
  62. package/dist/__tests__/execution-frame-order.unit.test.d.ts +2 -0
  63. package/dist/__tests__/execution-frame-order.unit.test.d.ts.map +1 -0
  64. package/dist/__tests__/execution-frame-protocol.unit.test.d.ts +2 -0
  65. package/dist/__tests__/execution-frame-protocol.unit.test.d.ts.map +1 -0
  66. package/dist/__tests__/execution-input-sample.contract.test.d.ts +2 -0
  67. package/dist/__tests__/execution-input-sample.contract.test.d.ts.map +1 -0
  68. package/dist/__tests__/execution-measurement.unit.test.d.ts +2 -0
  69. package/dist/__tests__/execution-measurement.unit.test.d.ts.map +1 -0
  70. package/dist/__tests__/execution-public-api.test-d.d.ts +2 -0
  71. package/dist/__tests__/execution-public-api.test-d.d.ts.map +1 -0
  72. package/dist/__tests__/execution-public-api.unit.test.d.ts +2 -0
  73. package/dist/__tests__/execution-public-api.unit.test.d.ts.map +1 -0
  74. package/dist/__tests__/execution-rebuild.contract.test.d.ts +2 -0
  75. package/dist/__tests__/execution-rebuild.contract.test.d.ts.map +1 -0
  76. package/dist/__tests__/execution-report.contract.test.d.ts +2 -0
  77. package/dist/__tests__/execution-report.contract.test.d.ts.map +1 -0
  78. package/dist/__tests__/execution-report.types.test-d.d.ts +2 -0
  79. package/dist/__tests__/execution-report.types.test-d.d.ts.map +1 -0
  80. package/dist/__tests__/execution-schema-freshness.unit.test.d.ts +2 -0
  81. package/dist/__tests__/execution-schema-freshness.unit.test.d.ts.map +1 -0
  82. package/dist/__tests__/execution-selector.unit.test.d.ts +2 -0
  83. package/dist/__tests__/execution-selector.unit.test.d.ts.map +1 -0
  84. package/dist/__tests__/execution-shared-kernel.unit.test.d.ts +2 -0
  85. package/dist/__tests__/execution-shared-kernel.unit.test.d.ts.map +1 -0
  86. package/dist/__tests__/execution-world-health-owner.test-d.d.ts +2 -0
  87. package/dist/__tests__/execution-world-health-owner.test-d.d.ts.map +1 -0
  88. package/dist/__tests__/fallback-camera.unit.test.d.ts +2 -0
  89. package/dist/__tests__/fallback-camera.unit.test.d.ts.map +1 -0
  90. package/dist/__tests__/frame-loop-time-elapsed.test.d.ts +2 -0
  91. package/dist/__tests__/frame-loop-time-elapsed.test.d.ts.map +1 -0
  92. package/dist/__tests__/frame-loop-time.unit.test.d.ts +2 -0
  93. package/dist/__tests__/frame-loop-time.unit.test.d.ts.map +1 -0
  94. package/dist/__tests__/frame-loop-world-array.test.d.ts +2 -0
  95. package/dist/__tests__/frame-loop-world-array.test.d.ts.map +1 -0
  96. package/dist/__tests__/kernel-worker-runtime.unit.test.d.ts +2 -0
  97. package/dist/__tests__/kernel-worker-runtime.unit.test.d.ts.map +1 -0
  98. package/dist/__tests__/migration-surface-inventory.unit.test.d.ts +2 -0
  99. package/dist/__tests__/migration-surface-inventory.unit.test.d.ts.map +1 -0
  100. package/dist/__tests__/plugin-policy.integration.test.d.ts +2 -0
  101. package/dist/__tests__/plugin-policy.integration.test.d.ts.map +1 -0
  102. package/dist/__tests__/plugin-profiles.unit.test.d.ts +2 -0
  103. package/dist/__tests__/plugin-profiles.unit.test.d.ts.map +1 -0
  104. package/dist/__tests__/profiler-default-off.test.d.ts +2 -0
  105. package/dist/__tests__/profiler-default-off.test.d.ts.map +1 -0
  106. package/dist/__tests__/profiler-frame-token.test.d.ts +2 -0
  107. package/dist/__tests__/profiler-frame-token.test.d.ts.map +1 -0
  108. package/dist/__tests__/profiler-phase-catalog.test.d.ts +2 -0
  109. package/dist/__tests__/profiler-phase-catalog.test.d.ts.map +1 -0
  110. package/dist/__tests__/profiler-phase-integration.test.d.ts +2 -0
  111. package/dist/__tests__/profiler-phase-integration.test.d.ts.map +1 -0
  112. package/dist/__tests__/profiler-remote-bridge.test.d.ts +2 -0
  113. package/dist/__tests__/profiler-remote-bridge.test.d.ts.map +1 -0
  114. package/dist/__tests__/remote-integration.test.d.ts +2 -0
  115. package/dist/__tests__/remote-integration.test.d.ts.map +1 -0
  116. package/dist/__tests__/remote-serve-flag.test.d.ts +2 -0
  117. package/dist/__tests__/remote-serve-flag.test.d.ts.map +1 -0
  118. package/dist/__tests__/rhi-capture-capability.test-d.d.ts +2 -0
  119. package/dist/__tests__/rhi-capture-capability.test-d.d.ts.map +1 -0
  120. package/dist/__tests__/rhi-capture-host.integration.test.d.ts +2 -0
  121. package/dist/__tests__/rhi-capture-host.integration.test.d.ts.map +1 -0
  122. package/dist/__tests__/rhi-capture.browser.test.d.ts +2 -0
  123. package/dist/__tests__/rhi-capture.browser.test.d.ts.map +1 -0
  124. package/dist/__tests__/surface-handoff.test.d.ts +2 -0
  125. package/dist/__tests__/surface-handoff.test.d.ts.map +1 -0
  126. package/dist/__tests__/types-m2.test-d.d.ts +2 -0
  127. package/dist/__tests__/types-m2.test-d.d.ts.map +1 -0
  128. package/dist/__tests__/visibility-remote.integration.test.d.ts +2 -0
  129. package/dist/__tests__/visibility-remote.integration.test.d.ts.map +1 -0
  130. package/dist/__tests__/worker-execution-stop.test.d.ts +2 -0
  131. package/dist/__tests__/worker-execution-stop.test.d.ts.map +1 -0
  132. package/dist/animation-asset-lookup.d.ts +8 -0
  133. package/dist/animation-asset-lookup.d.ts.map +1 -0
  134. package/dist/assets-runtime-assembly.d.ts +19 -0
  135. package/dist/assets-runtime-assembly.d.ts.map +1 -0
  136. package/dist/canvas-policy.d.ts +4 -0
  137. package/dist/canvas-policy.d.ts.map +1 -0
  138. package/dist/create-app.d.ts +57 -0
  139. package/dist/create-app.d.ts.map +1 -0
  140. package/dist/engine-worker-runtime.mjs +884 -0
  141. package/dist/engine-worker-runtime.mjs.map +1 -0
  142. package/dist/errors.d.ts +263 -0
  143. package/dist/errors.d.ts.map +1 -0
  144. package/dist/execution/attached-world-swap.d.ts +21 -0
  145. package/dist/execution/attached-world-swap.d.ts.map +1 -0
  146. package/dist/execution/bootstrap-entry.d.ts +30 -0
  147. package/dist/execution/bootstrap-entry.d.ts.map +1 -0
  148. package/dist/execution/capabilities.d.ts +5 -0
  149. package/dist/execution/capabilities.d.ts.map +1 -0
  150. package/dist/execution/control.d.ts +14 -0
  151. package/dist/execution/control.d.ts.map +1 -0
  152. package/dist/execution/engine-worker-runtime.d.ts +2 -0
  153. package/dist/execution/engine-worker-runtime.d.ts.map +1 -0
  154. package/dist/execution/engine-worker.d.ts +23 -0
  155. package/dist/execution/engine-worker.d.ts.map +1 -0
  156. package/dist/execution/host-controller.d.ts +16 -0
  157. package/dist/execution/host-controller.d.ts.map +1 -0
  158. package/dist/execution/index.d.ts +10 -0
  159. package/dist/execution/index.d.ts.map +1 -0
  160. package/dist/execution/kernel-pool.d.ts +14 -0
  161. package/dist/execution/kernel-pool.d.ts.map +1 -0
  162. package/dist/execution/kernel-worker-runtime.d.ts +2 -0
  163. package/dist/execution/kernel-worker-runtime.d.ts.map +1 -0
  164. package/dist/execution/measurement.d.ts +7 -0
  165. package/dist/execution/measurement.d.ts.map +1 -0
  166. package/dist/execution/protocol.d.ts +84 -0
  167. package/dist/execution/protocol.d.ts.map +1 -0
  168. package/dist/execution/report.d.ts +5 -0
  169. package/dist/execution/report.d.ts.map +1 -0
  170. package/dist/execution/schema.d.ts +4 -0
  171. package/dist/execution/schema.d.ts.map +1 -0
  172. package/dist/execution/selector.d.ts +10 -0
  173. package/dist/execution/selector.d.ts.map +1 -0
  174. package/dist/execution/types.d.ts +107 -0
  175. package/dist/execution/types.d.ts.map +1 -0
  176. package/dist/fallback-camera.d.ts +8 -0
  177. package/dist/fallback-camera.d.ts.map +1 -0
  178. package/dist/game-context.d.ts +98 -0
  179. package/dist/game-context.d.ts.map +1 -0
  180. package/dist/index.d.ts +24 -0
  181. package/dist/index.d.ts.map +1 -0
  182. package/dist/index.mjs +3985 -0
  183. package/dist/index.mjs.map +1 -0
  184. package/dist/input-map-plugin.d.ts +5 -0
  185. package/dist/input-map-plugin.d.ts.map +1 -0
  186. package/dist/input-plugin.d.ts +4 -0
  187. package/dist/input-plugin.d.ts.map +1 -0
  188. package/dist/internal/assembled-engine-profile.d.ts +5 -0
  189. package/dist/internal/assembled-engine-profile.d.ts.map +1 -0
  190. package/dist/internal/assets-world-plugin.d.ts +21 -0
  191. package/dist/internal/assets-world-plugin.d.ts.map +1 -0
  192. package/dist/internal/browser-remote-bridge.d.ts +25 -0
  193. package/dist/internal/browser-remote-bridge.d.ts.map +1 -0
  194. package/dist/internal/browser-rhi-debug-runtime.d.ts +18 -0
  195. package/dist/internal/browser-rhi-debug-runtime.d.ts.map +1 -0
  196. package/dist/internal/component-introspection.d.ts +24 -0
  197. package/dist/internal/component-introspection.d.ts.map +1 -0
  198. package/dist/internal/ecs-import.d.ts +6 -0
  199. package/dist/internal/ecs-import.d.ts.map +1 -0
  200. package/dist/internal/engine-profile-common.d.ts +9 -0
  201. package/dist/internal/engine-profile-common.d.ts.map +1 -0
  202. package/dist/internal/error-fanout.d.ts +66 -0
  203. package/dist/internal/error-fanout.d.ts.map +1 -0
  204. package/dist/internal/frame-loop.d.ts +35 -0
  205. package/dist/internal/frame-loop.d.ts.map +1 -0
  206. package/dist/internal/input-attach.d.ts +44 -0
  207. package/dist/internal/input-attach.d.ts.map +1 -0
  208. package/dist/internal/main-engine-profile.d.ts +15 -0
  209. package/dist/internal/main-engine-profile.d.ts.map +1 -0
  210. package/dist/internal/remote-serve-flag.d.ts +23 -0
  211. package/dist/internal/remote-serve-flag.d.ts.map +1 -0
  212. package/dist/internal/remote-server-plugin.d.ts +8 -0
  213. package/dist/internal/remote-server-plugin.d.ts.map +1 -0
  214. package/dist/internal/rhi-capture.d.ts +19 -0
  215. package/dist/internal/rhi-capture.d.ts.map +1 -0
  216. package/dist/internal/rhi-debug-flag.d.ts +18 -0
  217. package/dist/internal/rhi-debug-flag.d.ts.map +1 -0
  218. package/dist/internal/worker-engine-profile.d.ts +13 -0
  219. package/dist/internal/worker-engine-profile.d.ts.map +1 -0
  220. package/dist/kernel-worker-runtime.mjs +69 -0
  221. package/dist/kernel-worker-runtime.mjs.map +1 -0
  222. package/dist/load-game-errors.d.ts +131 -0
  223. package/dist/load-game-errors.d.ts.map +1 -0
  224. package/dist/load-game.d.ts +34 -0
  225. package/dist/load-game.d.ts.map +1 -0
  226. package/dist/plugin-factories.d.ts +3 -0
  227. package/dist/plugin-factories.d.ts.map +1 -0
  228. package/dist/tool-preview/bootstrap.d.ts +113 -0
  229. package/dist/tool-preview/bootstrap.d.ts.map +1 -0
  230. package/dist/tool-preview/evidence.d.ts +20 -0
  231. package/dist/tool-preview/evidence.d.ts.map +1 -0
  232. package/dist/tool-preview/framing.d.ts +13 -0
  233. package/dist/tool-preview/framing.d.ts.map +1 -0
  234. package/dist/tool-preview/recipe.d.ts +54 -0
  235. package/dist/tool-preview/recipe.d.ts.map +1 -0
  236. package/dist/types.d.ts +444 -0
  237. package/dist/types.d.ts.map +1 -0
  238. package/package.json +86 -0
  239. package/schema/execution-report.schema.json +168 -0
  240. package/src/__tests__/animation-asset-lookup.unit.test.ts +20 -0
  241. package/src/__tests__/app-rebuild.test.ts +31 -0
  242. package/src/__tests__/asset-registry-types.test-d.ts +16 -0
  243. package/src/__tests__/assets-runtime-assembly.integration.test.ts +68 -0
  244. package/src/__tests__/callback-deletion.unit.test.ts +14 -0
  245. package/src/__tests__/callback-inference.test-d.ts +32 -0
  246. package/src/__tests__/component-introspection.unit.test.ts +56 -0
  247. package/src/__tests__/consumer-migration.unit.test.ts +21 -0
  248. package/src/__tests__/create-app-features.test.ts +40 -0
  249. package/src/__tests__/create-app-listener-sync.test.ts +377 -0
  250. package/src/__tests__/create-app-lock-provider.test.ts +212 -0
  251. package/src/__tests__/create-app-plugin.test.ts +217 -0
  252. package/src/__tests__/create-app-stop.test.ts +617 -0
  253. package/src/__tests__/create-app-time.unit.test.ts +26 -0
  254. package/src/__tests__/create-app.test-d.ts +82 -0
  255. package/src/__tests__/create-app.test.ts +346 -0
  256. package/src/__tests__/create-renderer-error.integration.test.ts +24 -0
  257. package/src/__tests__/create-renderer-lifecycle.integration.test.ts +19 -0
  258. package/src/__tests__/docs-gate-green.unit.test.ts +61 -0
  259. package/src/__tests__/draw-source-headless-full-chain.test.ts +226 -0
  260. package/src/__tests__/draw-source-injected-world-update-visibility.test.ts +182 -0
  261. package/src/__tests__/draw-source-single-world-regression.test.ts +185 -0
  262. package/src/__tests__/ecs-import.unit.test.ts +40 -0
  263. package/src/__tests__/engine-worker-bootstrap-channel.unit.test.ts +74 -0
  264. package/src/__tests__/engine-worker-world-swap.unit.test.ts +76 -0
  265. package/src/__tests__/errors-pointer-lock-failed.test.ts +268 -0
  266. package/src/__tests__/execution-bootstrap-isolation.unit.test.ts +80 -0
  267. package/src/__tests__/execution-error-detail.test-d.ts +13 -0
  268. package/src/__tests__/execution-error-detail.unit.test.ts +35 -0
  269. package/src/__tests__/execution-frame-order.unit.test.ts +38 -0
  270. package/src/__tests__/execution-frame-protocol.unit.test.ts +61 -0
  271. package/src/__tests__/execution-input-sample.contract.test.ts +23 -0
  272. package/src/__tests__/execution-measurement.unit.test.ts +20 -0
  273. package/src/__tests__/execution-public-api.test-d.ts +10 -0
  274. package/src/__tests__/execution-public-api.unit.test.ts +9 -0
  275. package/src/__tests__/execution-rebuild.contract.test.ts +36 -0
  276. package/src/__tests__/execution-report.contract.test.ts +50 -0
  277. package/src/__tests__/execution-report.types.test-d.ts +16 -0
  278. package/src/__tests__/execution-schema-freshness.unit.test.ts +20 -0
  279. package/src/__tests__/execution-selector.unit.test.ts +72 -0
  280. package/src/__tests__/execution-shared-kernel.unit.test.ts +180 -0
  281. package/src/__tests__/execution-world-health-owner.test-d.ts +9 -0
  282. package/src/__tests__/fallback-camera.unit.test.ts +32 -0
  283. package/src/__tests__/frame-loop-time-elapsed.test.ts +115 -0
  284. package/src/__tests__/frame-loop-time.unit.test.ts +61 -0
  285. package/src/__tests__/frame-loop-world-array.test.ts +441 -0
  286. package/src/__tests__/kernel-worker-runtime.unit.test.ts +50 -0
  287. package/src/__tests__/migration-surface-inventory.unit.test.ts +13 -0
  288. package/src/__tests__/plugin-policy.integration.test.ts +15 -0
  289. package/src/__tests__/plugin-profiles.unit.test.ts +190 -0
  290. package/src/__tests__/profiler-default-off.test.ts +53 -0
  291. package/src/__tests__/profiler-frame-token.test.ts +248 -0
  292. package/src/__tests__/profiler-phase-catalog.test.ts +30 -0
  293. package/src/__tests__/profiler-phase-integration.test.ts +129 -0
  294. package/src/__tests__/profiler-remote-bridge.test.ts +31 -0
  295. package/src/__tests__/remote-integration.test.ts +224 -0
  296. package/src/__tests__/remote-serve-flag.test.ts +24 -0
  297. package/src/__tests__/rhi-capture-capability.test-d.ts +22 -0
  298. package/src/__tests__/rhi-capture-host.integration.test.ts +157 -0
  299. package/src/__tests__/rhi-capture.browser.test.ts +51 -0
  300. package/src/__tests__/surface-handoff.test.ts +92 -0
  301. package/src/__tests__/types-m2.test-d.ts +81 -0
  302. package/src/__tests__/visibility-remote.integration.test.ts +119 -0
  303. package/src/__tests__/worker-execution-stop.test.ts +116 -0
  304. package/src/animation-asset-lookup.ts +13 -0
  305. package/src/assets-runtime-assembly.ts +70 -0
  306. package/src/canvas-policy.ts +16 -0
  307. package/src/create-app.ts +1351 -0
  308. package/src/errors.ts +454 -0
  309. package/src/execution/attached-world-swap.ts +67 -0
  310. package/src/execution/bootstrap-entry.ts +122 -0
  311. package/src/execution/capabilities.ts +102 -0
  312. package/src/execution/control.ts +52 -0
  313. package/src/execution/engine-worker-runtime.ts +400 -0
  314. package/src/execution/engine-worker.ts +161 -0
  315. package/src/execution/host-controller.ts +454 -0
  316. package/src/execution/index.ts +43 -0
  317. package/src/execution/kernel-pool.ts +236 -0
  318. package/src/execution/kernel-worker-runtime.ts +103 -0
  319. package/src/execution/measurement.ts +39 -0
  320. package/src/execution/protocol.ts +131 -0
  321. package/src/execution/report.ts +66 -0
  322. package/src/execution/schema.ts +168 -0
  323. package/src/execution/selector.ts +98 -0
  324. package/src/execution/types.ts +146 -0
  325. package/src/fallback-camera.ts +20 -0
  326. package/src/game-context.ts +126 -0
  327. package/src/index.ts +176 -0
  328. package/src/input-map-plugin.ts +21 -0
  329. package/src/input-plugin.ts +26 -0
  330. package/src/internal/assembled-engine-profile.ts +22 -0
  331. package/src/internal/assets-world-plugin.ts +79 -0
  332. package/src/internal/browser-remote-bridge.ts +267 -0
  333. package/src/internal/browser-rhi-debug-runtime.ts +67 -0
  334. package/src/internal/component-introspection.ts +116 -0
  335. package/src/internal/ecs-import.ts +41 -0
  336. package/src/internal/engine-profile-common.ts +10 -0
  337. package/src/internal/error-fanout.ts +129 -0
  338. package/src/internal/frame-loop.ts +525 -0
  339. package/src/internal/input-attach.ts +104 -0
  340. package/src/internal/main-engine-profile.ts +80 -0
  341. package/src/internal/remote-serve-flag.ts +50 -0
  342. package/src/internal/remote-server-plugin.ts +16 -0
  343. package/src/internal/rhi-capture.ts +211 -0
  344. package/src/internal/rhi-debug-flag.ts +40 -0
  345. package/src/internal/worker-engine-profile.ts +47 -0
  346. package/src/load-game-errors.ts +211 -0
  347. package/src/load-game.ts +115 -0
  348. package/src/plugin-factories.ts +2 -0
  349. package/src/tool-preview/bootstrap.ts +848 -0
  350. package/src/tool-preview/evidence.ts +59 -0
  351. package/src/tool-preview/framing.ts +52 -0
  352. package/src/tool-preview/recipe.ts +137 -0
  353. package/src/types.ts +505 -0
  354. 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
+ }