@forgeax/engine-app 0.0.0-dev.8d955ade1c79

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