@bornengine/engine 0.4.16

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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +231 -0
  3. package/native/android/Cargo.lock +1848 -0
  4. package/native/android/Cargo.toml +24 -0
  5. package/native/android/src/lib.rs +702 -0
  6. package/native/ios/Cargo.lock +1690 -0
  7. package/native/ios/Cargo.toml +32 -0
  8. package/native/ios/src/lib.rs +1267 -0
  9. package/native/linux/Cargo.lock +3279 -0
  10. package/native/linux/Cargo.toml +29 -0
  11. package/native/linux/src/lib.rs +1331 -0
  12. package/native/macos/Cargo.lock +3310 -0
  13. package/native/macos/Cargo.toml +46 -0
  14. package/native/macos/src/lib.rs +1302 -0
  15. package/native/shared/Cargo.lock +1899 -0
  16. package/native/shared/Cargo.toml +62 -0
  17. package/native/shared/assets/default_font.ttf +0 -0
  18. package/native/shared/build.rs +270 -0
  19. package/native/shared/shaders/common/clouds.wgsl +122 -0
  20. package/native/shared/shaders/common/fog.wgsl +16 -0
  21. package/native/shared/shaders/common/foliage_wind.wgsl +98 -0
  22. package/native/shared/shaders/common/imposter.wgsl +112 -0
  23. package/native/shared/shaders/common/pbr.wgsl +186 -0
  24. package/native/shared/shaders/common/shadows.wgsl +186 -0
  25. package/native/shared/shaders/common/sky.wgsl +8 -0
  26. package/native/shared/shaders/common/tonemap.wgsl +25 -0
  27. package/native/shared/shaders/impulse_field.wgsl +57 -0
  28. package/native/shared/shaders/material_abi.wgsl +383 -0
  29. package/native/shared/shaders/materials/test_minimal.wgsl +42 -0
  30. package/native/shared/src/anim_mixer.rs +61 -0
  31. package/native/shared/src/attach.rs +263 -0
  32. package/native/shared/src/audio/decode.rs +123 -0
  33. package/native/shared/src/audio/mod.rs +863 -0
  34. package/native/shared/src/audio/render.rs +892 -0
  35. package/native/shared/src/audio/spsc.rs +156 -0
  36. package/native/shared/src/audio/stream.rs +226 -0
  37. package/native/shared/src/custom_shaders.rs +104 -0
  38. package/native/shared/src/decals.rs +245 -0
  39. package/native/shared/src/drs.rs +211 -0
  40. package/native/shared/src/engine.rs +261 -0
  41. package/native/shared/src/ffi.rs +116 -0
  42. package/native/shared/src/ffi_core/assets.rs +388 -0
  43. package/native/shared/src/ffi_core/audio_ffi.rs +184 -0
  44. package/native/shared/src/ffi_core/draw.rs +334 -0
  45. package/native/shared/src/ffi_core/game_loop.rs +577 -0
  46. package/native/shared/src/ffi_core/input.rs +234 -0
  47. package/native/shared/src/ffi_core/mod.rs +127 -0
  48. package/native/shared/src/ffi_core/models.rs +1154 -0
  49. package/native/shared/src/ffi_core/ragdoll_ffi.rs +261 -0
  50. package/native/shared/src/ffi_core/scene.rs +626 -0
  51. package/native/shared/src/ffi_core/vfx.rs +212 -0
  52. package/native/shared/src/ffi_core/visual.rs +691 -0
  53. package/native/shared/src/frame_callbacks.rs +122 -0
  54. package/native/shared/src/geometry.rs +236 -0
  55. package/native/shared/src/handles.rs +182 -0
  56. package/native/shared/src/input.rs +448 -0
  57. package/native/shared/src/jolt_sys.rs +822 -0
  58. package/native/shared/src/lib.rs +55 -0
  59. package/native/shared/src/models.rs +1093 -0
  60. package/native/shared/src/models_gltf.rs +1280 -0
  61. package/native/shared/src/particles.rs +391 -0
  62. package/native/shared/src/physics_jolt.rs +1908 -0
  63. package/native/shared/src/picking.rs +298 -0
  64. package/native/shared/src/postfx.rs +345 -0
  65. package/native/shared/src/profiler.rs +492 -0
  66. package/native/shared/src/ragdoll.rs +474 -0
  67. package/native/shared/src/renderer/atmosphere_lut.rs +573 -0
  68. package/native/shared/src/renderer/brdf_lut.rs +154 -0
  69. package/native/shared/src/renderer/draw2d.rs +143 -0
  70. package/native/shared/src/renderer/formats.rs +822 -0
  71. package/native/shared/src/renderer/froxel.rs +421 -0
  72. package/native/shared/src/renderer/gi_bake.rs +653 -0
  73. package/native/shared/src/renderer/graph.rs +462 -0
  74. package/native/shared/src/renderer/hiz.rs +269 -0
  75. package/native/shared/src/renderer/hot_reload.rs +390 -0
  76. package/native/shared/src/renderer/impulse_field.rs +456 -0
  77. package/native/shared/src/renderer/lighting.rs +154 -0
  78. package/native/shared/src/renderer/material_instancing.rs +171 -0
  79. package/native/shared/src/renderer/material_pipeline.rs +700 -0
  80. package/native/shared/src/renderer/material_system.rs +1996 -0
  81. package/native/shared/src/renderer/material_system_tests.rs +601 -0
  82. package/native/shared/src/renderer/material_system_wasm.rs +41 -0
  83. package/native/shared/src/renderer/mod.rs +12556 -0
  84. package/native/shared/src/renderer/model_draw.rs +641 -0
  85. package/native/shared/src/renderer/occlusion.rs +429 -0
  86. package/native/shared/src/renderer/planar_pass.rs +593 -0
  87. package/native/shared/src/renderer/planar_reflection.rs +499 -0
  88. package/native/shared/src/renderer/post_pass.rs +249 -0
  89. package/native/shared/src/renderer/postfx_chain.rs +728 -0
  90. package/native/shared/src/renderer/pt_pass.rs +577 -0
  91. package/native/shared/src/renderer/scene_pass.rs +607 -0
  92. package/native/shared/src/renderer/shader_include.rs +205 -0
  93. package/native/shared/src/renderer/shader_library.rs +135 -0
  94. package/native/shared/src/renderer/shaders/ao.rs +570 -0
  95. package/native/shared/src/renderer/shaders/core.rs +1243 -0
  96. package/native/shared/src/renderer/shaders/env.rs +907 -0
  97. package/native/shared/src/renderer/shaders/gi.rs +810 -0
  98. package/native/shared/src/renderer/shaders/mod.rs +19 -0
  99. package/native/shared/src/renderer/shaders/post.rs +1558 -0
  100. package/native/shared/src/renderer/shaders/pt.rs +1859 -0
  101. package/native/shared/src/renderer/shaders/ssgi.rs +1586 -0
  102. package/native/shared/src/renderer/shadow_pass.rs +731 -0
  103. package/native/shared/src/renderer/ssgi_pass.rs +392 -0
  104. package/native/shared/src/renderer/ssr_pass.rs +188 -0
  105. package/native/shared/src/renderer/texture_store.rs +473 -0
  106. package/native/shared/src/renderer/transient.rs +591 -0
  107. package/native/shared/src/renderer/types.rs +941 -0
  108. package/native/shared/src/renderer/util.rs +152 -0
  109. package/native/shared/src/scene.rs +1362 -0
  110. package/native/shared/src/sdf_cache.rs +274 -0
  111. package/native/shared/src/shadows.rs +1036 -0
  112. package/native/shared/src/staging.rs +102 -0
  113. package/native/shared/src/string_header.rs +266 -0
  114. package/native/shared/src/text_renderer.rs +502 -0
  115. package/native/shared/src/textures.rs +197 -0
  116. package/native/tvos/Cargo.lock +1693 -0
  117. package/native/tvos/Cargo.toml +36 -0
  118. package/native/tvos/metal-patched/Cargo.toml +178 -0
  119. package/native/tvos/metal-patched/LICENSE-APACHE +201 -0
  120. package/native/tvos/metal-patched/LICENSE-MIT +25 -0
  121. package/native/tvos/metal-patched/src/acceleration_structure.rs +667 -0
  122. package/native/tvos/metal-patched/src/acceleration_structure_pass.rs +108 -0
  123. package/native/tvos/metal-patched/src/argument.rs +366 -0
  124. package/native/tvos/metal-patched/src/blitpass.rs +102 -0
  125. package/native/tvos/metal-patched/src/buffer.rs +71 -0
  126. package/native/tvos/metal-patched/src/capturedescriptor.rs +76 -0
  127. package/native/tvos/metal-patched/src/capturemanager.rs +113 -0
  128. package/native/tvos/metal-patched/src/commandbuffer.rs +192 -0
  129. package/native/tvos/metal-patched/src/commandqueue.rs +44 -0
  130. package/native/tvos/metal-patched/src/computepass.rs +107 -0
  131. package/native/tvos/metal-patched/src/constants.rs +152 -0
  132. package/native/tvos/metal-patched/src/counters.rs +119 -0
  133. package/native/tvos/metal-patched/src/depthstencil.rs +190 -0
  134. package/native/tvos/metal-patched/src/device.rs +2134 -0
  135. package/native/tvos/metal-patched/src/drawable.rs +39 -0
  136. package/native/tvos/metal-patched/src/encoder.rs +2041 -0
  137. package/native/tvos/metal-patched/src/heap.rs +281 -0
  138. package/native/tvos/metal-patched/src/indirect_encoder.rs +344 -0
  139. package/native/tvos/metal-patched/src/lib.rs +657 -0
  140. package/native/tvos/metal-patched/src/library.rs +902 -0
  141. package/native/tvos/metal-patched/src/mps.rs +575 -0
  142. package/native/tvos/metal-patched/src/pipeline/compute.rs +475 -0
  143. package/native/tvos/metal-patched/src/pipeline/mod.rs +71 -0
  144. package/native/tvos/metal-patched/src/pipeline/render.rs +762 -0
  145. package/native/tvos/metal-patched/src/renderpass.rs +443 -0
  146. package/native/tvos/metal-patched/src/resource.rs +182 -0
  147. package/native/tvos/metal-patched/src/sampler.rs +165 -0
  148. package/native/tvos/metal-patched/src/sync.rs +178 -0
  149. package/native/tvos/metal-patched/src/texture.rs +352 -0
  150. package/native/tvos/metal-patched/src/types.rs +90 -0
  151. package/native/tvos/metal-patched/src/vertexdescriptor.rs +250 -0
  152. package/native/tvos/src/audio_backend.rs +197 -0
  153. package/native/tvos/src/lib.rs +1891 -0
  154. package/native/visionos/Cargo.lock +1693 -0
  155. package/native/visionos/Cargo.toml +40 -0
  156. package/native/visionos/src/audio_backend.rs +197 -0
  157. package/native/visionos/src/lib.rs +1887 -0
  158. package/native/watchos/Cargo.lock +16 -0
  159. package/native/watchos/Cargo.toml +19 -0
  160. package/native/watchos/shaders/bloom_postfx.metal +99 -0
  161. package/native/watchos/src/BloomWatchApp.swift +1267 -0
  162. package/native/watchos/src/BloomWatchAudio.swift +179 -0
  163. package/native/watchos/src/audio.rs +55 -0
  164. package/native/watchos/src/draw_list.rs +229 -0
  165. package/native/watchos/src/ffi_stubs.rs +915 -0
  166. package/native/watchos/src/ffi_stubs_manual.rs +35 -0
  167. package/native/watchos/src/lib.rs +1124 -0
  168. package/native/watchos/src/models.rs +746 -0
  169. package/native/watchos/src/postfx.rs +95 -0
  170. package/native/watchos/src/scene.rs +534 -0
  171. package/native/watchos/src/textures.rs +184 -0
  172. package/native/web/Cargo.lock +1657 -0
  173. package/native/web/Cargo.toml +43 -0
  174. package/native/web/bloom_glue.js +695 -0
  175. package/native/web/build.sh +131 -0
  176. package/native/web/index.html +35 -0
  177. package/native/web/jolt_bridge.js +1519 -0
  178. package/native/web/src/input_ffi.rs +286 -0
  179. package/native/web/src/lib.rs +1796 -0
  180. package/native/web/src/material_ffi.rs +710 -0
  181. package/native/web/src/parity_ffi.rs +343 -0
  182. package/native/web/src/physics_ffi.rs +643 -0
  183. package/native/web/src/ragdoll_ffi.rs +250 -0
  184. package/native/web/src/render_settings.rs +98 -0
  185. package/native/windows/Cargo.lock +1815 -0
  186. package/native/windows/Cargo.toml +68 -0
  187. package/native/windows/src/lib.rs +1486 -0
  188. package/package.json +4279 -0
  189. package/src/audio/index.ts +315 -0
  190. package/src/core/colors.ts +63 -0
  191. package/src/core/index.ts +1206 -0
  192. package/src/core/keys.ts +63 -0
  193. package/src/core/types.ts +104 -0
  194. package/src/index.ts +171 -0
  195. package/src/math/index.ts +516 -0
  196. package/src/mobile/index.ts +294 -0
  197. package/src/models/index.ts +1258 -0
  198. package/src/physics/index.ts +1134 -0
  199. package/src/scene/index.ts +698 -0
  200. package/src/shapes/index.ts +120 -0
  201. package/src/text/index.ts +48 -0
  202. package/src/textures/index.ts +187 -0
  203. package/src/vfx/index.ts +191 -0
  204. package/src/world/index.ts +24 -0
  205. package/src/world/loader.ts +423 -0
  206. package/src/world/prefab.ts +217 -0
  207. package/src/world/render.ts +172 -0
  208. package/src/world/saver.ts +108 -0
  209. package/src/world/serialize.ts +301 -0
  210. package/src/world/terrain.ts +355 -0
  211. package/src/world/types.ts +160 -0
  212. package/src/world/validate.ts +319 -0
  213. package/src/world/version.ts +114 -0
@@ -0,0 +1,695 @@
1
+ /**
2
+ * Bloom Engine — Web Bootstrap & FFI Bridge
3
+ *
4
+ * Loaded as a deferred ES module by both the standalone template (`index.html`,
5
+ * engine-only / no game) and the build-assembled page that `build.sh` produces
6
+ * by splicing this bootstrap into Perry's self-contained WASM HTML.
7
+ *
8
+ * Responsibilities:
9
+ * 1. Load the Bloom rendering engine WASM (`pkg/bloom_web.js`, wasm-pack).
10
+ * 2. Initialise JoltPhysics.js (best-effort; local vendored copy, then CDN).
11
+ * 3. Prefetch the game's asset tree (assets_manifest.json, if the build
12
+ * emitted one) with a progress readout, so the game's synchronous loads
13
+ * hit a warm cache instead of issuing one blocking XHR per file.
14
+ * 4. Publish `globalThis.__ffiImports` — the `bloom_*` functions the Perry
15
+ * game WASM imports under its `"ffi"` namespace.
16
+ * 5. Pre-initialise the wgpu window and wait for the async device setup,
17
+ * so the game's synchronous `main()` never races "Engine not
18
+ * initialized" (Perry main is sync; wgpu init is async).
19
+ * 6. Wire DOM input (keyboard/mouse/touch/pointer-lock), HiDPI canvas
20
+ * sizing, Web Audio, and the rAF game loop.
21
+ * 7. Resolve `window.__bloomReady` so the gated `bootPerryWasm(...)` call
22
+ * in the page can instantiate the game WASM *after* all of the above.
23
+ *
24
+ * --- The FFI value contract (important) ---
25
+ * Perry's WASM runtime (`wasm_runtime.js`, embedded in the page) wraps the
26
+ * entire `ffi` namespace with `wrapFfiForI64`: it DECODES each NaN-boxed i64
27
+ * argument to a plain JS value (number / string / handle) before calling us,
28
+ * and RE-ENCODES our plain return value. So every function below receives and
29
+ * returns ordinary JS values — there is no manual NaN-boxing to do here. A
30
+ * `bloom_draw_text(text, ...)` import arrives with `text` already a JS string.
31
+ */
32
+
33
+ import init, * as bloom from './pkg/bloom_web.js';
34
+
35
+ let bloomModule = null;
36
+ let booted = false;
37
+
38
+ // --- Game loop state ---
39
+ let gameCallback = null; // Perry closure handle captured from bloom_run_game
40
+ let gameRunning = false;
41
+ let rafId = null;
42
+
43
+ // --- Asset prefetch cache ---
44
+ // path → Uint8Array. Filled from assets_manifest.json before the game boots;
45
+ // syncFetchBytes serves from it and only falls back to a blocking XHR for
46
+ // paths the manifest didn't know about.
47
+ const assetCache = new Map();
48
+ let manifestPaths = null; // Set<string> when a manifest was loaded
49
+
50
+ // --- Pointer lock state ---
51
+ // The game expresses intent through bloom_disable_cursor/enable_cursor; the
52
+ // browser only grants pointer lock inside a user gesture, and revokes it on
53
+ // ESC without delivering the keydown. Both mismatches are reconciled here.
54
+ let wantPointerLock = false;
55
+
56
+ /**
57
+ * Idempotent engine bootstrap. Safe to call multiple times; only the first
58
+ * call performs work. Returns the resolved Bloom wasm-bindgen module.
59
+ */
60
+ export async function bootBloomGame() {
61
+ if (booted) return bloomModule;
62
+ booted = true;
63
+
64
+ const loading = document.getElementById('loading');
65
+ const setStatus = (text) => { if (loading) loading.textContent = text; };
66
+
67
+ // 1. Load Bloom engine WASM.
68
+ setStatus('Loading engine…');
69
+ await init();
70
+ bloomModule = bloom;
71
+
72
+ // 2. Initialise Jolt physics (WASM build of Jolt). The jolt_bridge.js module
73
+ // wasm-bindgen bundled into pkg/snippets/ owns all physics state; we reach
74
+ // it via the Rust-exported helper so bloom_physics_* talk to one instance.
75
+ // Resolution order: explicit override, a vendored local copy (offline
76
+ // builds), then the CDN.
77
+ try {
78
+ let factory = globalThis.__joltFactory;
79
+ if (!factory) {
80
+ try {
81
+ factory = (await import(new URL('./jolt-physics.mjs', import.meta.url))).default;
82
+ } catch {
83
+ factory = (await import('https://cdn.jsdelivr.net/npm/jolt-physics@1.0.0/+esm')).default;
84
+ }
85
+ }
86
+ await bloom.bloom_physics_init_jolt(factory);
87
+ console.log('[bloom] Jolt physics ready');
88
+ } catch (e) {
89
+ console.warn('[bloom] Jolt init failed:', e, '- bloom_physics_* calls will be no-ops');
90
+ }
91
+
92
+ // 3. Prefetch the asset tree, if the build emitted a manifest. Progress is
93
+ // the one thing a frozen synchronous boot cannot show, so it is shown
94
+ // HERE, before the game WASM gets control.
95
+ await prefetchAssets(setStatus);
96
+
97
+ // 4. Publish the FFI surface for the Perry game WASM.
98
+ const ffi = buildFfiImports();
99
+ if (typeof globalThis.__ffiImports === 'undefined') {
100
+ globalThis.__ffiImports = ffi;
101
+ } else {
102
+ Object.assign(globalThis.__ffiImports, ffi);
103
+ }
104
+
105
+ // 5. DOM wiring (input + HiDPI canvas sizing + audio unlock).
106
+ setupDomBridge();
107
+
108
+ // 6. Pre-initialise the window and WAIT for the async wgpu setup. Perry's
109
+ // main() is synchronous and calls engine functions immediately after its
110
+ // own initWindow (which is an idempotent no-op once this ran) — booting
111
+ // the game before the device exists panics with "Engine not initialized".
112
+ setStatus('Starting renderer…');
113
+ // Probe for an actual WebGPU adapter first: having the API surface
114
+ // (navigator.gpu) is not having an adapter — Chrome ships the API even
115
+ // when acceleration is off or the GPU is blocklisted, and wgpu would then
116
+ // panic inside the wasm. Fail here with an actionable message instead.
117
+ if (!navigator.gpu) {
118
+ throw new Error('This browser has no WebGPU support. Use a current '
119
+ + 'Chrome/Edge (113+) or Firefox (141+).');
120
+ }
121
+ const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' })
122
+ .catch(() => null);
123
+ if (!adapter) {
124
+ throw new Error('No WebGPU adapter available. Check chrome://gpu — '
125
+ + 'hardware acceleration must be enabled and the GPU not blocklisted.');
126
+ }
127
+ const canvas = document.getElementById('bloom-canvas');
128
+ const w = (canvas && canvas.clientWidth) || window.innerWidth || 1280;
129
+ const h = (canvas && canvas.clientHeight) || window.innerHeight || 720;
130
+ bloom.bloom_init_window(w, h, 0, 0);
131
+ const deadline = performance.now() + 15000;
132
+ while (bloom.bloom_is_initialized() < 0.5) {
133
+ if (performance.now() > deadline) {
134
+ throw new Error(
135
+ 'WebGPU device setup timed out. Check chrome://gpu — a WebGPU adapter '
136
+ + 'is required (hardware acceleration on, GPU not blocklisted).');
137
+ }
138
+ await new Promise((r) => setTimeout(r, 16));
139
+ }
140
+
141
+ // 7. Hand over to the game. Keep the loading indicator up — the game's own
142
+ // synchronous boot (asset decode, pool init) runs next; remove it on the
143
+ // first real frame instead.
144
+ setStatus('Starting game…');
145
+ console.log('[bloom] engine + FFI ready');
146
+ return bloomModule;
147
+ }
148
+
149
+ /** Fetch assets_manifest.json and warm the cache, 8 files at a time. */
150
+ async function prefetchAssets(setStatus) {
151
+ let manifest = null;
152
+ try {
153
+ const res = await fetch('assets_manifest.json');
154
+ if (res.ok) manifest = await res.json();
155
+ } catch { /* no manifest = per-file sync XHR, same as before */ }
156
+ if (!manifest || !Array.isArray(manifest.files)) return;
157
+
158
+ manifestPaths = new Set(manifest.files);
159
+ const queue = manifest.files.slice();
160
+ const total = queue.length;
161
+ let done = 0;
162
+ setStatus(`Loading assets… 0/${total}`);
163
+
164
+ async function worker() {
165
+ for (;;) {
166
+ const path = queue.shift();
167
+ if (path === undefined) return;
168
+ try {
169
+ const res = await fetch(path);
170
+ if (res.ok) assetCache.set(path, new Uint8Array(await res.arrayBuffer()));
171
+ } catch { /* miss -> the sync XHR fallback will report it */ }
172
+ done++;
173
+ if (done % 5 === 0 || done === total) setStatus(`Loading assets… ${done}/${total}`);
174
+ }
175
+ }
176
+ const workers = [];
177
+ for (let i = 0; i < 8; i++) workers.push(worker());
178
+ await Promise.all(workers);
179
+ }
180
+
181
+ /**
182
+ * Build the `bloom_*` FFI import object. Defaults pass straight through to the
183
+ * matching wasm-bindgen export (correct for all numeric-in/out and string-out
184
+ * functions); the overrides below handle string-in, file loading, the host
185
+ * surfaces (window/title/audio/fullscreen/cursor/storage), and the game loop.
186
+ */
187
+ function buildFfiImports() {
188
+ const imports = {};
189
+
190
+ // Default: every bloom_* export, passed plain values by Perry's wrapFfiForI64.
191
+ for (const [name, fn] of Object.entries(bloom)) {
192
+ if (typeof fn === 'function' && name.startsWith('bloom_')) {
193
+ imports[name] = fn;
194
+ }
195
+ }
196
+
197
+ // --- Text: string params route to the _str variants ---
198
+ imports.bloom_draw_text = (text, x, y, size, r, g, b, a) =>
199
+ bloom.bloom_draw_text_str(String(text), x, y, size, r, g, b, a);
200
+ imports.bloom_draw_text_ex = (font, text, x, y, size, spacing, r, g, b, a) =>
201
+ bloom.bloom_draw_text_ex_str(font, String(text), x, y, size, spacing, r, g, b, a);
202
+ imports.bloom_measure_text = (text, size) =>
203
+ bloom.bloom_measure_text_str(String(text), size);
204
+ imports.bloom_measure_text_ex = (font, text, size, spacing) =>
205
+ bloom.bloom_measure_text_ex_str(font, String(text), size, spacing);
206
+
207
+ // --- Materials & post-FX: shader source strings route to _str variants ---
208
+ const materialVariants = [
209
+ 'bloom_compile_material', 'bloom_compile_material_refractive',
210
+ 'bloom_compile_material_transparent', 'bloom_compile_material_additive',
211
+ 'bloom_compile_material_cutout', 'bloom_compile_material_instanced',
212
+ 'bloom_add_post_pass', 'bloom_set_post_pass',
213
+ ];
214
+ for (const name of materialVariants) {
215
+ const strFn = bloom[name + '_str'];
216
+ if (strFn) imports[name] = (source) => strFn(String(source));
217
+ }
218
+ imports.bloom_compile_material_instanced_bucket = (source, bucket, readsScene) =>
219
+ bloom.bloom_compile_material_instanced_bucket(String(source), bucket, readsScene);
220
+
221
+ // compile_material_from_file: no web filesystem — fetch the source and
222
+ // compile it into the requested bucket (0 opaque | 1 transparent |
223
+ // 2 refractive | 3 additive | 4 cutout — the TS wrapper's encoding).
224
+ imports.bloom_compile_material_from_file = (path, bucketKind) => {
225
+ const src = syncFetchText(String(path));
226
+ if (src == null) return 0;
227
+ switch (bucketKind | 0) {
228
+ case 1: return bloom.bloom_compile_material_transparent_str(src);
229
+ case 2: return bloom.bloom_compile_material_refractive_str(src);
230
+ case 3: return bloom.bloom_compile_material_additive_str(src);
231
+ case 4: return bloom.bloom_compile_material_cutout_str(src);
232
+ default: return bloom.bloom_compile_material_str(src);
233
+ }
234
+ };
235
+
236
+ // Material params via the legacy pointer signature: Perry's wrapFfiForI64
237
+ // hands us the array as a plain JS array, so route it to the floats entry.
238
+ imports.bloom_set_material_params = (handle, params, _count) => {
239
+ if (params && typeof params.length === 'number') {
240
+ bloom.bloom_set_material_params_floats(handle, Float32Array.from(params));
241
+ }
242
+ };
243
+
244
+ // --- Asset loading: fetch the file synchronously, hand bytes to _bytes ---
245
+ const byteLoaders = {
246
+ bloom_load_texture: 'bloom_load_texture_bytes',
247
+ bloom_load_font: 'bloom_load_font_bytes',
248
+ bloom_load_sound: 'bloom_load_sound_bytes',
249
+ bloom_load_music: 'bloom_load_music_bytes',
250
+ bloom_load_model: 'bloom_load_model_bytes',
251
+ bloom_load_model_animation: 'bloom_load_model_animation_bytes',
252
+ bloom_load_image: 'bloom_load_image_bytes',
253
+ bloom_stage_model: 'bloom_stage_model_bytes',
254
+ bloom_set_env_clear_from_hdr: 'bloom_set_env_clear_from_hdr_bytes',
255
+ };
256
+ for (const [name, bytesName] of Object.entries(byteLoaders)) {
257
+ const bytesFn = bloom[bytesName];
258
+ if (!bytesFn) continue;
259
+ imports[name] = (path) => {
260
+ const data = syncFetchBytes(String(path));
261
+ return data ? bytesFn(data) : 0;
262
+ };
263
+ }
264
+
265
+ // Texture array from a comma-separated path list: fetch each file's bytes,
266
+ // decode + assemble engine-side (same codecs as the native from-files FFI).
267
+ imports.bloom_create_texture_array_from_files = (paths, format, mipLevels) => {
268
+ if (!bloom.bloom_texture_array_files_push) return 0;
269
+ bloom.bloom_texture_array_files_reset();
270
+ for (const raw of String(paths).split(',')) {
271
+ const p = raw.trim();
272
+ if (!p) continue;
273
+ const data = syncFetchBytes(p);
274
+ if (data) bloom.bloom_texture_array_files_push(data);
275
+ else console.warn('[texarray] missing layer file:', p);
276
+ }
277
+ return bloom.bloom_texture_array_files_commit(format, mipLevels);
278
+ };
279
+
280
+ // --- Window / title ---
281
+ imports.bloom_init_window = (w, h, title, fullscreen) => {
282
+ document.title = String(title) || 'Bloom Engine';
283
+ // Web export ignores the title slot (_title: f64); pass 0 for it.
284
+ bloom.bloom_init_window(w, h, 0, fullscreen);
285
+ };
286
+ imports.bloom_set_window_title = (title) => { document.title = String(title); };
287
+
288
+ // --- Web Audio ---
289
+ imports.bloom_init_audio = () => initAudioBridge();
290
+ imports.bloom_close_audio = () => closeAudioBridge();
291
+
292
+ // --- Fullscreen / cursor ---
293
+ imports.bloom_toggle_fullscreen = () => {
294
+ const canvas = document.getElementById('bloom-canvas');
295
+ if (!document.fullscreenElement) canvas?.requestFullscreen?.().catch(() => {});
296
+ else document.exitFullscreen().catch(() => {});
297
+ };
298
+ imports.bloom_disable_cursor = () => {
299
+ wantPointerLock = true;
300
+ document.getElementById('bloom-canvas')?.requestPointerLock?.();
301
+ bloom.bloom_disable_cursor();
302
+ };
303
+ imports.bloom_enable_cursor = () => {
304
+ wantPointerLock = false;
305
+ document.exitPointerLock?.();
306
+ bloom.bloom_enable_cursor();
307
+ };
308
+
309
+ // --- File I/O: localStorage first (saves/settings), then the served
310
+ // asset tree (read-only data files like world JSON and manifests) ---
311
+ const LS_PREFIX = 'bloom_fs:';
312
+ imports.bloom_write_file = (path, data) => {
313
+ try { localStorage.setItem(LS_PREFIX + String(path), String(data)); return 1; }
314
+ catch { return 0; }
315
+ };
316
+ imports.bloom_file_exists = (path) => {
317
+ const p = String(path);
318
+ if (localStorage.getItem(LS_PREFIX + p) !== null) return 1;
319
+ if (manifestPaths) return manifestPaths.has(p) ? 1 : 0;
320
+ return syncFetchBytes(p) !== null ? 1 : 0;
321
+ };
322
+ imports.bloom_read_file = (path) => {
323
+ const p = String(path);
324
+ const v = localStorage.getItem(LS_PREFIX + p);
325
+ if (v !== null) return v;
326
+ const text = syncFetchText(p);
327
+ return text === null ? '' : text; // plain string; Perry re-encodes via wrapFfiForI64
328
+ };
329
+
330
+ // --- Game loop ---
331
+ // runGame() on web (bloom_get_platform() === 7) just hands its update closure
332
+ // to bloom_run_game and returns; the blocking native loop is never entered.
333
+ // We capture the closure and drive it from requestAnimationFrame.
334
+ imports.bloom_run_game = (callback) => {
335
+ gameCallback = callback;
336
+ if (!gameRunning) {
337
+ gameRunning = true;
338
+ startRafLoop();
339
+ }
340
+ };
341
+ // Safety net for a game that still spins `while (!windowShouldClose())`:
342
+ // report "should close" once the rAF loop owns frame pacing, so the stray
343
+ // loop exits after one iteration instead of hanging the tab.
344
+ imports.bloom_window_should_close = () => (gameRunning ? 1 : 0);
345
+ imports.bloom_close_window = () => stopGame();
346
+
347
+ // Last: wrap every entry so (a) a throw names the FFI call that produced
348
+ // it — Perry's boot catch reports only the message, which for an ABI
349
+ // mismatch reads like a riddle — and (b) BigInt args (manifest-declared
350
+ // i64 params, e.g. pointer slots) are coerced to Numbers, since every
351
+ // implementation here is a plain JS/wasm-bindgen function expecting f64.
352
+ for (const [name, fn] of Object.entries(imports)) {
353
+ imports[name] = (...args) => {
354
+ for (let i = 0; i < args.length; i++) {
355
+ if (typeof args[i] === 'bigint') args[i] = Number(args[i]);
356
+ }
357
+ try {
358
+ return fn(...args);
359
+ } catch (e) {
360
+ console.error('[bloom ffi]', name, 'threw:', e, e?.stack ?? '');
361
+ throw e;
362
+ }
363
+ };
364
+ }
365
+
366
+ return imports;
367
+ }
368
+
369
+ /**
370
+ * Drive the captured Perry game closure once per animation frame:
371
+ * flush queued input → begin_drawing → callback(dt) → end_drawing.
372
+ *
373
+ * The closure is invoked through Perry's `callWasmClosure`, a global helper its
374
+ * runtime exposes that resolves the closure's function-table index + captures
375
+ * against the live game WASM instance. By the time the first frame runs, the
376
+ * runtime classic <script> has executed, so the global is defined.
377
+ */
378
+ function startRafLoop() {
379
+ let firstFrame = true;
380
+ function frame() {
381
+ if (!gameRunning) return;
382
+ if (firstFrame) {
383
+ firstFrame = false;
384
+ document.getElementById('loading')?.remove();
385
+ }
386
+ flushInput();
387
+ bloom.bloom_begin_drawing();
388
+ if (gameCallback !== null && typeof globalThis.callWasmClosure === 'function') {
389
+ const dt = bloom.bloom_get_delta_time();
390
+ try {
391
+ globalThis.callWasmClosure(gameCallback, dt);
392
+ } catch (e) {
393
+ console.error('[bloom] game frame threw; stopping loop:', e);
394
+ gameRunning = false;
395
+ }
396
+ }
397
+ bloom.bloom_end_drawing();
398
+ rafId = requestAnimationFrame(frame);
399
+ }
400
+ rafId = requestAnimationFrame(frame);
401
+ }
402
+
403
+ /** Stop the rAF loop. */
404
+ export function stopGame() {
405
+ gameRunning = false;
406
+ if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; }
407
+ }
408
+
409
+ // --- Synchronous asset fetching ---
410
+ // Perry calls load functions synchronously (like native). The prefetch cache
411
+ // (assets_manifest.json) makes the common case a Map lookup; anything else
412
+ // blocks on a sync XHR, which is fine for startup-time same-origin loads.
413
+ const textCache = new Map();
414
+ function syncFetchBytes(url) {
415
+ const cached = assetCache.get(url);
416
+ if (cached !== undefined) return cached;
417
+ // A manifest is authoritative for the asset tree: paths it doesn't list
418
+ // are treated as absent without a blocking round-trip (level probes and
419
+ // optional-sound checks would otherwise stall boot by one XHR each).
420
+ if (manifestPaths && url.indexOf('://') < 0 && !manifestPaths.has(url)) return null;
421
+ try {
422
+ const xhr = new XMLHttpRequest();
423
+ xhr.open('GET', url, false);
424
+ xhr.responseType = 'arraybuffer';
425
+ xhr.send();
426
+ if (xhr.status === 200 || xhr.status === 0) {
427
+ const bytes = new Uint8Array(xhr.response);
428
+ assetCache.set(url, bytes);
429
+ return bytes;
430
+ }
431
+ } catch (e) {
432
+ console.warn('[bloom] syncFetchBytes failed for', url, e);
433
+ }
434
+ return null;
435
+ }
436
+ function syncFetchText(url) {
437
+ const hit = textCache.get(url);
438
+ if (hit !== undefined) return hit;
439
+ const bytes = syncFetchBytes(url);
440
+ const text = bytes ? new TextDecoder().decode(bytes) : null;
441
+ textCache.set(url, text);
442
+ return text;
443
+ }
444
+
445
+ // --- Web Audio bridge ---
446
+ // The Rust mixer is pulled by a ScriptProcessorNode; creation is attempted at
447
+ // bloom_init_audio, but browsers gate (or suspend) an AudioContext until a
448
+ // user gesture, so the first pointerdown/keydown resumes it.
449
+ let audioContext = null;
450
+ let audioProcessor = null;
451
+ function initAudioBridge() {
452
+ if (audioContext) return;
453
+ try {
454
+ audioContext = new AudioContext({ sampleRate: 44100 });
455
+ const bufSize = 4096;
456
+ audioProcessor = audioContext.createScriptProcessor(bufSize, 0, 2);
457
+ audioProcessor.onaudioprocess = (e) => {
458
+ const left = e.outputBuffer.getChannelData(0);
459
+ const right = e.outputBuffer.getChannelData(1);
460
+ const interleaved = new Float32Array(left.length * 2);
461
+ bloom.bloom_audio_mix(interleaved);
462
+ for (let i = 0; i < left.length; i++) {
463
+ left[i] = interleaved[i * 2];
464
+ right[i] = interleaved[i * 2 + 1];
465
+ }
466
+ };
467
+ audioProcessor.connect(audioContext.destination);
468
+ } catch (e) {
469
+ console.warn('[bloom] Web Audio init failed:', e);
470
+ }
471
+ }
472
+ function closeAudioBridge() {
473
+ if (audioProcessor) { audioProcessor.disconnect(); audioProcessor = null; }
474
+ if (audioContext) { audioContext.close(); audioContext = null; }
475
+ }
476
+ function resumeAudio() {
477
+ if (audioContext && audioContext.state === 'suspended') {
478
+ audioContext.resume().catch(() => {});
479
+ }
480
+ }
481
+
482
+ // --- Input event queue ---
483
+ // Browser input events fire in their own tasks — always BETWEEN frames on a
484
+ // single-threaded wasm host — but a down+up pair that both land in one
485
+ // inter-frame gap would cancel out before the engine's edge detector ever
486
+ // sees the key. So events are queued and drained at a fixed point (top of
487
+ // frame), and an -up whose -down arrived in the same batch is deferred one
488
+ // frame so the tap stays visible. (Same model Bloom Jump ships with.)
489
+ const inputQueue = [];
490
+ let deferredEvents = [];
491
+ function flushInput() {
492
+ const seenDown = new Set();
493
+ const defer = [];
494
+ for (const ev of deferredEvents) applyInputEvent(ev);
495
+ deferredEvents = defer;
496
+ for (const ev of inputQueue) {
497
+ if (ev.up) {
498
+ if (seenDown.has(ev.k)) { defer.push(ev); continue; }
499
+ applyInputEvent(ev);
500
+ } else {
501
+ seenDown.add(ev.k);
502
+ applyInputEvent(ev);
503
+ }
504
+ }
505
+ deferredEvents = defer;
506
+ inputQueue.length = 0;
507
+ }
508
+ function applyInputEvent(ev) {
509
+ if (ev.kind === 'key') {
510
+ if (ev.up) bloom.bloom_inject_key_up(ev.code);
511
+ else bloom.bloom_inject_key_down(ev.code);
512
+ } else if (ev.kind === 'mouse') {
513
+ if (ev.up) bloom.bloom_inject_mouse_button_up(ev.code);
514
+ else bloom.bloom_inject_mouse_button_down(ev.code);
515
+ }
516
+ }
517
+
518
+ /** DOM input + HiDPI canvas sizing. */
519
+ function setupDomBridge() {
520
+ const canvas = document.getElementById('bloom-canvas');
521
+ if (!canvas) return;
522
+
523
+ // HiDPI: keep the canvas backing store + renderer surface in sync with the
524
+ // CSS box and devicePixelRatio. Clamp dpr to 3 to bound GPU cost.
525
+ function syncCanvasSize() {
526
+ const rect = canvas.getBoundingClientRect();
527
+ const logW = Math.max(1, Math.round(rect.width));
528
+ const logH = Math.max(1, Math.round(rect.height));
529
+ const dpr = Math.min(3, Math.max(1, window.devicePixelRatio || 1));
530
+ const physW = Math.round(logW * dpr);
531
+ const physH = Math.round(logH * dpr);
532
+ if (canvas.width !== physW) canvas.width = physW;
533
+ if (canvas.height !== physH) canvas.height = physH;
534
+ if (typeof bloom.bloom_resize === 'function') bloom.bloom_resize(physW, physH, logW, logH);
535
+ }
536
+ if (typeof ResizeObserver !== 'undefined') new ResizeObserver(syncCanvasSize).observe(canvas);
537
+ function watchDpr() {
538
+ const q = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
539
+ q.addEventListener('change', () => { syncCanvasSize(); watchDpr(); }, { once: true });
540
+ }
541
+ watchDpr();
542
+
543
+ // Keyboard. Codes are Bloom's Key enum (engine/src/core/keys.ts), which is
544
+ // NOT GLFW: ENTER=265, ESCAPE=27, UP=256, F1=112, LEFT_SHIFT=280. The old
545
+ // map here used GLFW values, so Enter/Escape/arrows/modifiers/F-keys never
546
+ // matched what games test with isKeyPressed(Key.X) — menus went dead while
547
+ // WASD kept working (letters happen to agree). Keep in lockstep with
548
+ // keys.ts; jump's web/bloom_ffi.js carries the same table.
549
+ const keyMap = {
550
+ 'KeyA': 65, 'KeyB': 66, 'KeyC': 67, 'KeyD': 68, 'KeyE': 69, 'KeyF': 70,
551
+ 'KeyG': 71, 'KeyH': 72, 'KeyI': 73, 'KeyJ': 74, 'KeyK': 75, 'KeyL': 76,
552
+ 'KeyM': 77, 'KeyN': 78, 'KeyO': 79, 'KeyP': 80, 'KeyQ': 81, 'KeyR': 82,
553
+ 'KeyS': 83, 'KeyT': 84, 'KeyU': 85, 'KeyV': 86, 'KeyW': 87, 'KeyX': 88,
554
+ 'KeyY': 89, 'KeyZ': 90,
555
+ 'Digit0': 48, 'Digit1': 49, 'Digit2': 50, 'Digit3': 51, 'Digit4': 52,
556
+ 'Digit5': 53, 'Digit6': 54, 'Digit7': 55, 'Digit8': 56, 'Digit9': 57,
557
+ 'Space': 32, 'Enter': 265, 'Escape': 27, 'Backspace': 8, 'Tab': 9,
558
+ 'ArrowUp': 256, 'ArrowDown': 257, 'ArrowLeft': 258, 'ArrowRight': 259,
559
+ 'ShiftLeft': 280, 'ShiftRight': 281, 'ControlLeft': 282, 'ControlRight': 283,
560
+ 'AltLeft': 284, 'AltRight': 285,
561
+ 'F1': 112, 'F2': 113, 'F3': 114, 'F4': 115, 'F5': 116, 'F6': 117,
562
+ 'F7': 118, 'F8': 119, 'F9': 120, 'F10': 121, 'F11': 122, 'F12': 123,
563
+ 'Comma': 44, 'Period': 46, 'Slash': 47, 'Semicolon': 59, 'Quote': 39,
564
+ 'BracketLeft': 91, 'BracketRight': 93, 'Backslash': 92, 'Minus': 45, 'Equal': 61,
565
+ 'Backquote': 96, 'Delete': 127, 'Insert': 260, 'Home': 261, 'End': 262,
566
+ 'PageUp': 263, 'PageDown': 264,
567
+ };
568
+ document.addEventListener('keydown', (e) => {
569
+ resumeAudio();
570
+ const code = keyMap[e.code];
571
+ if (code !== undefined) {
572
+ inputQueue.push({ kind: 'key', k: 'k' + code, code, up: false });
573
+ if (e.code !== 'F12' && e.code !== 'F5') e.preventDefault();
574
+ }
575
+ if (typeof bloom.bloom_inject_char === 'function'
576
+ && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
577
+ bloom.bloom_inject_char(e.key.codePointAt(0));
578
+ }
579
+ });
580
+ document.addEventListener('keyup', (e) => {
581
+ const code = keyMap[e.code];
582
+ if (code !== undefined) {
583
+ inputQueue.push({ kind: 'key', k: 'k' + code, code, up: true });
584
+ e.preventDefault();
585
+ }
586
+ });
587
+
588
+ // Mouse. DOM buttons are 0=left / 1=middle / 2=right; Bloom's MouseButton
589
+ // is LEFT=0 / RIGHT=1 / MIDDLE=2.
590
+ const domButtonToBloom = [0, 2, 1, 3, 4];
591
+ canvas.addEventListener('mousemove', (e) => {
592
+ if (document.pointerLockElement === canvas) {
593
+ bloom.bloom_inject_mouse_delta(e.movementX, e.movementY);
594
+ } else {
595
+ const rect = canvas.getBoundingClientRect();
596
+ bloom.bloom_inject_mouse_move(e.clientX - rect.left, e.clientY - rect.top);
597
+ }
598
+ });
599
+ canvas.addEventListener('mousedown', (e) => {
600
+ resumeAudio();
601
+ // Re-acquire pointer lock on click when the game wants it (the browser
602
+ // released it on ESC, or never granted it because the request happened
603
+ // outside a user gesture).
604
+ if (wantPointerLock && document.pointerLockElement !== canvas) {
605
+ canvas.requestPointerLock?.();
606
+ }
607
+ const b = domButtonToBloom[e.button];
608
+ if (b !== undefined) inputQueue.push({ kind: 'mouse', k: 'm' + b, code: b, up: false });
609
+ e.preventDefault();
610
+ });
611
+ document.addEventListener('mouseup', (e) => {
612
+ const b = domButtonToBloom[e.button];
613
+ if (b !== undefined) inputQueue.push({ kind: 'mouse', k: 'm' + b, code: b, up: true });
614
+ });
615
+ canvas.addEventListener('contextmenu', (e) => e.preventDefault());
616
+ canvas.addEventListener('wheel', (e) => {
617
+ // deltaMode 0 = pixels (~100/notch); 1 = lines (~3/notch). Normalise to
618
+ // roughly ±1 per notch, positive = wheel up (native convention).
619
+ const notches = e.deltaMode === 1 ? -e.deltaY / 3 : -e.deltaY / 100;
620
+ bloom.bloom_inject_mouse_wheel(notches);
621
+ e.preventDefault();
622
+ }, { passive: false });
623
+
624
+ // Pointer lock: the browser exits on ESC without delivering the keydown.
625
+ // If the game still wanted the lock, surface that exit as an ESC press so
626
+ // its pause-menu binding fires.
627
+ document.addEventListener('pointerlockchange', () => {
628
+ if (document.pointerLockElement !== canvas && wantPointerLock) {
629
+ inputQueue.push({ kind: 'key', k: 'k27', code: 27, up: false }); // Key.ESCAPE
630
+ inputQueue.push({ kind: 'key', k: 'k27', code: 27, up: true });
631
+ }
632
+ });
633
+
634
+ // Touch. Browser touch identifiers are arbitrary; map them onto stable
635
+ // slots 0..N-1 the way the engine's sparse touch API expects. Release goes
636
+ // through the deferred path so a same-frame tap stays visible.
637
+ const touchSlots = new Map(); // identifier → slot
638
+ function slotFor(id) {
639
+ let s = touchSlots.get(id);
640
+ if (s !== undefined) return s;
641
+ const used = new Set(touchSlots.values());
642
+ for (s = 0; used.has(s); s++);
643
+ touchSlots.set(id, s);
644
+ return s;
645
+ }
646
+ function touchXY(t) {
647
+ const rect = canvas.getBoundingClientRect();
648
+ return [t.clientX - rect.left, t.clientY - rect.top];
649
+ }
650
+ canvas.addEventListener('touchstart', (e) => {
651
+ resumeAudio();
652
+ for (const t of e.changedTouches) {
653
+ const [x, y] = touchXY(t);
654
+ bloom.bloom_inject_touch(slotFor(t.identifier), x, y, 1);
655
+ }
656
+ e.preventDefault();
657
+ }, { passive: false });
658
+ canvas.addEventListener('touchmove', (e) => {
659
+ for (const t of e.changedTouches) {
660
+ const [x, y] = touchXY(t);
661
+ bloom.bloom_inject_touch(slotFor(t.identifier), x, y, 1);
662
+ }
663
+ e.preventDefault();
664
+ }, { passive: false });
665
+ const touchEnd = (e) => {
666
+ for (const t of e.changedTouches) {
667
+ const s = touchSlots.get(t.identifier);
668
+ if (s === undefined) continue;
669
+ const [x, y] = touchXY(t);
670
+ bloom.bloom_inject_touch_release(s, x, y);
671
+ touchSlots.delete(t.identifier);
672
+ }
673
+ e.preventDefault();
674
+ };
675
+ canvas.addEventListener('touchend', touchEnd, { passive: false });
676
+ canvas.addEventListener('touchcancel', touchEnd, { passive: false });
677
+
678
+ document.addEventListener('pointerdown', resumeAudio);
679
+ }
680
+
681
+ // --- Auto-boot on import ---
682
+ // The page creates `window.__bloomReady` (a pending promise) in a classic
683
+ // inline <script> during parse; the gated `bootPerryWasm(...)` call awaits it.
684
+ // We resolve it once the engine + FFI are live so the game WASM instantiates
685
+ // against a ready host. If no resolver was installed (engine-only standalone
686
+ // use), this is harmless.
687
+ bootBloomGame().then(
688
+ () => { globalThis.__bloomReadyResolve?.(); },
689
+ (err) => {
690
+ console.error('[bloom] bootstrap failed:', err);
691
+ const loading = document.getElementById('loading');
692
+ if (loading) loading.textContent = 'Failed to start: ' + (err?.message ?? err);
693
+ globalThis.__bloomReadyReject?.(err);
694
+ },
695
+ );