@bloomengine/engine 0.3.1 → 0.3.3

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 (65) hide show
  1. package/README.md +33 -11
  2. package/native/linux/Cargo.lock +1571 -12
  3. package/native/linux/Cargo.toml +3 -0
  4. package/native/linux/src/lib.rs +745 -40
  5. package/native/macos/src/lib.rs +30 -118
  6. package/native/shared/build.rs +32 -4
  7. package/native/shared/src/postfx.rs +16 -10
  8. package/native/shared/src/renderer/formats.rs +1 -7
  9. package/native/shared/src/renderer/impulse_field.rs +2 -1
  10. package/native/shared/src/renderer/material_system.rs +15 -3
  11. package/native/shared/src/renderer/shaders.rs +7 -1
  12. package/native/shared/src/renderer/transient.rs +12 -12
  13. package/native/shared/src/string_header.rs +32 -0
  14. package/native/third_party/JoltPhysics/Build/Android/PerformanceTest/build.gradle +51 -0
  15. package/native/third_party/JoltPhysics/Build/Android/PerformanceTest/src/main/AndroidManifest.xml +20 -0
  16. package/native/third_party/JoltPhysics/Build/Android/PerformanceTest/src/main/cpp/CMakeLists.txt +20 -0
  17. package/native/third_party/JoltPhysics/Build/Android/UnitTests/build.gradle +51 -0
  18. package/native/third_party/JoltPhysics/Build/Android/UnitTests/src/main/AndroidManifest.xml +20 -0
  19. package/native/third_party/JoltPhysics/Build/Android/UnitTests/src/main/cpp/CMakeLists.txt +20 -0
  20. package/native/third_party/JoltPhysics/Build/Android/build.gradle +17 -0
  21. package/native/third_party/JoltPhysics/Build/Android/gradle/wrapper/gradle-wrapper.jar +0 -0
  22. package/native/third_party/JoltPhysics/Build/Android/gradle/wrapper/gradle-wrapper.properties +5 -0
  23. package/native/third_party/JoltPhysics/Build/Android/gradle.properties +21 -0
  24. package/native/third_party/JoltPhysics/Build/Android/gradlew +185 -0
  25. package/native/third_party/JoltPhysics/Build/Android/gradlew.bat +89 -0
  26. package/native/third_party/JoltPhysics/Build/Android/settings.gradle +10 -0
  27. package/native/third_party/JoltPhysics/Build/CMakeLists.txt +449 -0
  28. package/native/third_party/JoltPhysics/Build/README.md +250 -0
  29. package/native/third_party/JoltPhysics/Build/cmake_linux_clang_gcc.sh +28 -0
  30. package/native/third_party/JoltPhysics/Build/cmake_linux_emscripten.sh +19 -0
  31. package/native/third_party/JoltPhysics/Build/cmake_linux_mingw.sh +19 -0
  32. package/native/third_party/JoltPhysics/Build/cmake_vs2022_cl.bat +3 -0
  33. package/native/third_party/JoltPhysics/Build/cmake_vs2022_cl_32bit.bat +3 -0
  34. package/native/third_party/JoltPhysics/Build/cmake_vs2022_cl_arm.bat +3 -0
  35. package/native/third_party/JoltPhysics/Build/cmake_vs2022_cl_arm_32bit.bat +4 -0
  36. package/native/third_party/JoltPhysics/Build/cmake_vs2022_cl_cross_platform_deterministic.bat +3 -0
  37. package/native/third_party/JoltPhysics/Build/cmake_vs2022_cl_double.bat +3 -0
  38. package/native/third_party/JoltPhysics/Build/cmake_vs2022_cl_no_object_stream.bat +3 -0
  39. package/native/third_party/JoltPhysics/Build/cmake_vs2022_clang.bat +10 -0
  40. package/native/third_party/JoltPhysics/Build/cmake_vs2022_clang_cross_platform_deterministic.bat +10 -0
  41. package/native/third_party/JoltPhysics/Build/cmake_vs2022_clang_double.bat +10 -0
  42. package/native/third_party/JoltPhysics/Build/cmake_vs2022_uwp.bat +5 -0
  43. package/native/third_party/JoltPhysics/Build/cmake_vs2022_uwp_arm.bat +5 -0
  44. package/native/third_party/JoltPhysics/Build/cmake_vs2026_cl.bat +3 -0
  45. package/native/third_party/JoltPhysics/Build/cmake_vs2026_cl_cross_platform_deterministic.bat +3 -0
  46. package/native/third_party/JoltPhysics/Build/cmake_vs2026_cl_double.bat +3 -0
  47. package/native/third_party/JoltPhysics/Build/cmake_vs2026_clang.bat +10 -0
  48. package/native/third_party/JoltPhysics/Build/cmake_vs2026_clang_cross_platform_deterministic.bat +10 -0
  49. package/native/third_party/JoltPhysics/Build/cmake_vs2026_clang_double.bat +10 -0
  50. package/native/third_party/JoltPhysics/Build/cmake_windows_mingw.sh +19 -0
  51. package/native/third_party/JoltPhysics/Build/cmake_xcode_ios.sh +4 -0
  52. package/native/third_party/JoltPhysics/Build/cmake_xcode_macos.sh +4 -0
  53. package/native/third_party/JoltPhysics/Build/iOS/JoltViewerInfo.plist +34 -0
  54. package/native/third_party/JoltPhysics/Build/iOS/SamplesInfo.plist +34 -0
  55. package/native/third_party/JoltPhysics/Build/iOS/UnitTestsInfo.plist +34 -0
  56. package/native/third_party/JoltPhysics/Build/macOS/icon.icns +0 -0
  57. package/native/third_party/JoltPhysics/Build/macos_install_vulkan_sdk.sh +13 -0
  58. package/native/third_party/JoltPhysics/Build/ubuntu24_install_vulkan_sdk.sh +4 -0
  59. package/native/third_party/bloom_jolt/CMakeLists.txt +14 -5
  60. package/native/windows/Cargo.lock +1 -0
  61. package/native/windows/Cargo.toml +10 -1
  62. package/native/windows/src/lib.rs +226 -18
  63. package/package.json +9 -7
  64. package/src/core/colors.ts +34 -27
  65. package/src/core/index.ts +1 -2
@@ -1,6 +1,6 @@
1
1
  use bloom_shared::engine::EngineState;
2
2
  use bloom_shared::renderer::Renderer;
3
- use bloom_shared::string_header::str_from_header;
3
+ use bloom_shared::string_header::{str_from_header, alloc_perry_string};
4
4
  use bloom_shared::audio::{parse_wav, parse_ogg, parse_mp3};
5
5
 
6
6
  use std::sync::OnceLock;
@@ -66,9 +66,29 @@ mod x11_impl {
66
66
  static mut DISPLAY: *mut x11::xlib::Display = std::ptr::null_mut();
67
67
  static mut X11_WINDOW: x11::xlib::Window = 0;
68
68
  static mut IS_FULLSCREEN: bool = false;
69
+ static mut HEADLESS: bool = false;
70
+ static mut NO_FULLSCREEN: bool = false;
71
+ static mut CURSOR_HIDDEN: bool = false;
72
+ static mut HIDDEN_CURSOR: x11::xlib::Cursor = 0;
73
+ /// Cached XC_* shape cursors keyed by `cursor_shape` value 0..=6.
74
+ /// Lazily created by `apply_cursor_shape`; reused across frames so
75
+ /// we don't leak a Cursor handle every poll.
76
+ static mut SHAPE_CURSORS: [x11::xlib::Cursor; 8] = [0; 8];
77
+ static mut LAST_APPLIED_SHAPE: u32 = 0xFFFF_FFFF;
78
+ /// When `cursor_disabled` (relative-mode) is on we keep warping the
79
+ /// pointer back to window center each frame; remembering the last warp
80
+ /// target lets motion handlers compute a reliable raw delta and ignore
81
+ /// the synthetic motion event the warp itself generates.
82
+ static mut WARP_CENTER_X: i32 = 0;
83
+ static mut WARP_CENTER_Y: i32 = 0;
84
+ static mut RELATIVE_MODE: bool = false;
69
85
 
70
86
  pub fn set_fullscreen(fullscreen: bool) {
71
87
  unsafe {
88
+ // BLOOM_NO_FULLSCREEN=1 hard-disables the fullscreen path so
89
+ // benchmark harnesses on a 4K monitor don't silently 4× their
90
+ // pixel count when an inherited fullscreen Space leaks in.
91
+ if NO_FULLSCREEN && fullscreen { return; }
72
92
  if DISPLAY.is_null() || X11_WINDOW == 0 { return; }
73
93
 
74
94
  let wm_state = x11::xlib::XInternAtom(
@@ -115,8 +135,14 @@ mod x11_impl {
115
135
  /// are *logical*; on a HiDPI X11 display we multiply by the
116
136
  /// monitor's scale factor so the window appears the right size
117
137
  /// while its surface is at the screen's physical resolution.
118
- pub fn create_window(width: f64, height: f64, title: &str) -> (u32, u32) {
138
+ ///
139
+ /// `headless = true` keeps the X11 window + Vulkan surface alive
140
+ /// (wgpu needs a surface) but never maps the window — so it never
141
+ /// appears on screen and never steals focus. Mirrors the macOS
142
+ /// BLOOM_HEADLESS path for batch / CI rendering harnesses.
143
+ pub fn create_window(width: f64, height: f64, title: &str, headless: bool) -> (u32, u32) {
119
144
  unsafe {
145
+ HEADLESS = headless;
120
146
  DISPLAY = x11::xlib::XOpenDisplay(std::ptr::null());
121
147
  if DISPLAY.is_null() {
122
148
  panic!("Failed to open X11 display");
@@ -129,9 +155,13 @@ mod x11_impl {
129
155
  let phys_w = (width * scale).round() as u32;
130
156
  let phys_h = (height * scale).round() as u32;
131
157
 
158
+ // Off-screen origin is belt-and-braces: even if some WM
159
+ // chooses to map the window despite us never calling
160
+ // XMapWindow, it'll appear far off the visible desktop.
161
+ let origin_x: i32 = if headless { -20000 } else { 0 };
132
162
  X11_WINDOW = x11::xlib::XCreateSimpleWindow(
133
163
  DISPLAY, root,
134
- 0, 0, phys_w, phys_h, 0,
164
+ origin_x, 0, phys_w, phys_h, 0,
135
165
  x11::xlib::XBlackPixel(DISPLAY, screen),
136
166
  x11::xlib::XWhitePixel(DISPLAY, screen),
137
167
  );
@@ -144,12 +174,17 @@ mod x11_impl {
144
174
  x11::xlib::ButtonPressMask | x11::xlib::ButtonReleaseMask |
145
175
  x11::xlib::PointerMotionMask | x11::xlib::StructureNotifyMask);
146
176
 
147
- x11::xlib::XMapWindow(DISPLAY, X11_WINDOW);
177
+ if !headless {
178
+ x11::xlib::XMapWindow(DISPLAY, X11_WINDOW);
179
+ }
148
180
  x11::xlib::XFlush(DISPLAY);
149
181
  (phys_w, phys_h)
150
182
  }
151
183
  }
152
184
 
185
+ pub fn set_no_fullscreen(no_fs: bool) { unsafe { NO_FULLSCREEN = no_fs; } }
186
+ pub fn is_headless() -> bool { unsafe { HEADLESS } }
187
+
153
188
  /// Read the current display's DPI scale factor. Computed from
154
189
  /// physical screen dimensions (pixels / mm). Snapped to the
155
190
  /// nearest 0.25 and clamped to [1.0, 4.0] — EDID often lies
@@ -173,6 +208,170 @@ mod x11_impl {
173
208
  pub fn display() -> *mut x11::xlib::Display { unsafe { DISPLAY } }
174
209
  pub fn window() -> x11::xlib::Window { unsafe { X11_WINDOW } }
175
210
 
211
+ pub fn set_window_title(title: &str) {
212
+ unsafe {
213
+ if DISPLAY.is_null() || X11_WINDOW == 0 { return; }
214
+ let cstr = match std::ffi::CString::new(title) { Ok(c) => c, Err(_) => return };
215
+ x11::xlib::XStoreName(DISPLAY, X11_WINDOW, cstr.as_ptr());
216
+ // Modern WMs honour _NET_WM_NAME (UTF-8) over the legacy
217
+ // WM_NAME (Latin-1) set by XStoreName, so write both.
218
+ let net_wm_name = x11::xlib::XInternAtom(
219
+ DISPLAY, b"_NET_WM_NAME\0".as_ptr() as *const _, 0);
220
+ let utf8_string = x11::xlib::XInternAtom(
221
+ DISPLAY, b"UTF8_STRING\0".as_ptr() as *const _, 0);
222
+ if net_wm_name != 0 && utf8_string != 0 {
223
+ x11::xlib::XChangeProperty(
224
+ DISPLAY, X11_WINDOW, net_wm_name, utf8_string, 8,
225
+ x11::xlib::PropModeReplace,
226
+ title.as_ptr(), title.len() as i32,
227
+ );
228
+ }
229
+ x11::xlib::XFlush(DISPLAY);
230
+ }
231
+ }
232
+
233
+ /// Set the EWMH `_NET_WM_ICON` property from an image file. The X11
234
+ /// icon format is a flat array of CARDINALs (`long`s on the wire):
235
+ /// `[width, height, pixel0, pixel1, ...]` with each pixel as ARGB
236
+ /// premultiplied-alpha packed into the low 32 bits of a long.
237
+ pub fn set_window_icon(path: &str) {
238
+ unsafe {
239
+ if DISPLAY.is_null() || X11_WINDOW == 0 { return; }
240
+ let img = match image::open(path) {
241
+ Ok(i) => i.to_rgba8(),
242
+ Err(_) => return,
243
+ };
244
+ let (w, h) = (img.width() as usize, img.height() as usize);
245
+ let mut buf: Vec<std::os::raw::c_long> = Vec::with_capacity(2 + w * h);
246
+ buf.push(w as std::os::raw::c_long);
247
+ buf.push(h as std::os::raw::c_long);
248
+ for px in img.chunks_exact(4) {
249
+ let r = px[0] as u32;
250
+ let g = px[1] as u32;
251
+ let b = px[2] as u32;
252
+ let a = px[3] as u32;
253
+ let argb = (a << 24) | (r << 16) | (g << 8) | b;
254
+ buf.push(argb as std::os::raw::c_long);
255
+ }
256
+ let net_wm_icon = x11::xlib::XInternAtom(
257
+ DISPLAY, b"_NET_WM_ICON\0".as_ptr() as *const _, 0);
258
+ let cardinal = x11::xlib::XInternAtom(
259
+ DISPLAY, b"CARDINAL\0".as_ptr() as *const _, 0);
260
+ x11::xlib::XChangeProperty(
261
+ DISPLAY, X11_WINDOW, net_wm_icon, cardinal, 32,
262
+ x11::xlib::PropModeReplace,
263
+ buf.as_ptr() as *const u8,
264
+ buf.len() as i32,
265
+ );
266
+ x11::xlib::XFlush(DISPLAY);
267
+ }
268
+ }
269
+
270
+ /// Build (once) a 1x1 fully-transparent cursor — the standard X11
271
+ /// trick for "hide the cursor". Subsequent calls reuse the cached
272
+ /// cursor since X11 leaks Cursor handles otherwise.
273
+ unsafe fn ensure_hidden_cursor() -> x11::xlib::Cursor {
274
+ if HIDDEN_CURSOR != 0 { return HIDDEN_CURSOR; }
275
+ let pixmap = x11::xlib::XCreatePixmap(DISPLAY, X11_WINDOW, 1, 1, 1);
276
+ let mut color: x11::xlib::XColor = std::mem::zeroed();
277
+ let cursor = x11::xlib::XCreatePixmapCursor(
278
+ DISPLAY, pixmap, pixmap, &mut color, &mut color, 0, 0);
279
+ x11::xlib::XFreePixmap(DISPLAY, pixmap);
280
+ HIDDEN_CURSOR = cursor;
281
+ cursor
282
+ }
283
+
284
+ pub fn hide_cursor() {
285
+ unsafe {
286
+ if DISPLAY.is_null() || X11_WINDOW == 0 || CURSOR_HIDDEN { return; }
287
+ let c = ensure_hidden_cursor();
288
+ x11::xlib::XDefineCursor(DISPLAY, X11_WINDOW, c);
289
+ x11::xlib::XFlush(DISPLAY);
290
+ CURSOR_HIDDEN = true;
291
+ }
292
+ }
293
+
294
+ pub fn show_cursor() {
295
+ unsafe {
296
+ if DISPLAY.is_null() || X11_WINDOW == 0 || !CURSOR_HIDDEN { return; }
297
+ x11::xlib::XUndefineCursor(DISPLAY, X11_WINDOW);
298
+ x11::xlib::XFlush(DISPLAY);
299
+ CURSOR_HIDDEN = false;
300
+ }
301
+ }
302
+
303
+ /// Warp the pointer to window center and remember where we put it so
304
+ /// the motion handler can compute deltas relative to the warp target.
305
+ pub fn warp_to_center() {
306
+ unsafe {
307
+ if DISPLAY.is_null() || X11_WINDOW == 0 { return; }
308
+ let mut attrs: x11::xlib::XWindowAttributes = std::mem::zeroed();
309
+ x11::xlib::XGetWindowAttributes(DISPLAY, X11_WINDOW, &mut attrs);
310
+ let cx = attrs.width / 2;
311
+ let cy = attrs.height / 2;
312
+ x11::xlib::XWarpPointer(DISPLAY, 0, X11_WINDOW, 0, 0, 0, 0, cx, cy);
313
+ x11::xlib::XFlush(DISPLAY);
314
+ WARP_CENTER_X = cx;
315
+ WARP_CENTER_Y = cy;
316
+ }
317
+ }
318
+
319
+ pub fn enter_relative_mode() {
320
+ unsafe {
321
+ RELATIVE_MODE = true;
322
+ hide_cursor();
323
+ warp_to_center();
324
+ }
325
+ }
326
+
327
+ pub fn leave_relative_mode() {
328
+ unsafe {
329
+ RELATIVE_MODE = false;
330
+ show_cursor();
331
+ }
332
+ }
333
+
334
+ pub fn is_relative_mode() -> bool { unsafe { RELATIVE_MODE } }
335
+ pub fn warp_center_x() -> i32 { unsafe { WARP_CENTER_X } }
336
+ pub fn warp_center_y() -> i32 { unsafe { WARP_CENTER_Y } }
337
+
338
+ /// Apply the requested cursor shape (the same 0..=6 enum macOS uses
339
+ /// in NSCursor calls). XCreateFontCursor uses cursor-font glyph
340
+ /// constants from <X11/cursorfont.h>; we cache one Cursor per shape
341
+ /// so repeat calls don't leak server-side state.
342
+ pub fn apply_cursor_shape(shape: u32) {
343
+ unsafe {
344
+ if DISPLAY.is_null() || X11_WINDOW == 0 || CURSOR_HIDDEN { return; }
345
+ if shape == LAST_APPLIED_SHAPE { return; }
346
+ // X11 cursor-font glyph indices (from cursorfont.h).
347
+ // 0 = default arrow → XC_left_ptr (68)
348
+ // 1 = pointing hand → XC_hand2 (60)
349
+ // 2 = open hand → XC_fleur (52, "move")
350
+ // 3 = I-beam → XC_xterm (152)
351
+ // 4 = resize H → XC_sb_h_double_arrow (108)
352
+ // 5 = resize V → XC_sb_v_double_arrow (116)
353
+ // 6 = crosshair → XC_crosshair (34)
354
+ let glyph: u32 = match shape {
355
+ 1 => 60,
356
+ 2 => 52,
357
+ 3 => 152,
358
+ 4 => 108,
359
+ 5 => 116,
360
+ 6 => 34,
361
+ _ => 68,
362
+ };
363
+ let idx = (shape as usize).min(SHAPE_CURSORS.len() - 1);
364
+ if SHAPE_CURSORS[idx] == 0 {
365
+ SHAPE_CURSORS[idx] = x11::xlib::XCreateFontCursor(DISPLAY, glyph);
366
+ }
367
+ if SHAPE_CURSORS[idx] != 0 {
368
+ x11::xlib::XDefineCursor(DISPLAY, X11_WINDOW, SHAPE_CURSORS[idx]);
369
+ x11::xlib::XFlush(DISPLAY);
370
+ LAST_APPLIED_SHAPE = shape;
371
+ }
372
+ }
373
+ }
374
+
176
375
  pub fn poll_events() {
177
376
  unsafe {
178
377
  while x11::xlib::XPending(DISPLAY) > 0 {
@@ -181,7 +380,6 @@ mod x11_impl {
181
380
 
182
381
  match event.type_ {
183
382
  x11::xlib::KeyPress => {
184
- let key_event = event.key;
185
383
  let keysym = x11::xlib::XLookupKeysym(
186
384
  &mut event.key as *mut _ as *mut _,
187
385
  0,
@@ -190,6 +388,27 @@ mod x11_impl {
190
388
  if bloom_key > 0 {
191
389
  engine().input.set_key_down(bloom_key);
192
390
  }
391
+ // Decode UTF-8 typed text via XLookupString so the
392
+ // editor's text-input widget receives characters.
393
+ let mut buf = [0u8; 32];
394
+ let mut ks: x11::xlib::KeySym = 0;
395
+ let len = x11::xlib::XLookupString(
396
+ &mut event.key as *mut _,
397
+ buf.as_mut_ptr() as *mut i8,
398
+ buf.len() as i32,
399
+ &mut ks,
400
+ std::ptr::null_mut(),
401
+ );
402
+ if len > 0 {
403
+ if let Ok(s) = std::str::from_utf8(&buf[..len as usize]) {
404
+ for c in s.chars() {
405
+ let cp = c as u32;
406
+ if cp >= 32 || cp == 8 || cp == 13 || cp == 9 {
407
+ engine().input.push_char(cp);
408
+ }
409
+ }
410
+ }
411
+ }
193
412
  }
194
413
  x11::xlib::KeyRelease => {
195
414
  let keysym = x11::xlib::XLookupKeysym(
@@ -203,7 +422,21 @@ mod x11_impl {
203
422
  }
204
423
  x11::xlib::MotionNotify => {
205
424
  let motion = event.motion;
206
- engine().input.set_mouse_position(motion.x as f64, motion.y as f64);
425
+ if RELATIVE_MODE {
426
+ // Ignore the motion event we generated by warping
427
+ // back to center — it would otherwise add a stray
428
+ // -delta cancelling the user's actual movement.
429
+ if motion.x == WARP_CENTER_X && motion.y == WARP_CENTER_Y {
430
+ // synthetic warp event; skip
431
+ } else {
432
+ let dx = (motion.x - WARP_CENTER_X) as f64;
433
+ let dy = (motion.y - WARP_CENTER_Y) as f64;
434
+ engine().input.accumulate_mouse_delta(dx, dy);
435
+ warp_to_center();
436
+ }
437
+ } else {
438
+ engine().input.set_mouse_position(motion.x as f64, motion.y as f64);
439
+ }
207
440
  }
208
441
  x11::xlib::ButtonPress => {
209
442
  let button = event.button.button;
@@ -211,6 +444,12 @@ mod x11_impl {
211
444
  1 => engine().input.set_mouse_button_down(0),
212
445
  3 => engine().input.set_mouse_button_down(1),
213
446
  2 => engine().input.set_mouse_button_down(2),
447
+ // X11 maps wheel up/down to button 4/5 and
448
+ // horizontal scroll to 6/7. macOS uses an
449
+ // accumulator with positive = scroll up; flip
450
+ // the sign on the down case to match.
451
+ 4 => engine().input.accumulate_mouse_wheel(1.0),
452
+ 5 => engine().input.accumulate_mouse_wheel(-1.0),
214
453
  _ => {}
215
454
  }
216
455
  }
@@ -256,7 +495,23 @@ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u
256
495
 
257
496
  #[cfg(target_os = "linux")]
258
497
  {
259
- let (phys_w, phys_h) = x11_impl::create_window(width, height, title);
498
+ // Headless mode: BLOOM_HEADLESS=1 keeps the X11 window + Vulkan
499
+ // surface alive (wgpu requires a real surface) but never maps
500
+ // the window so it's invisible and never steals focus. Lets an
501
+ // agent spin up the renderer in a batch loop without disturbing
502
+ // the user's desktop.
503
+ let headless = std::env::var("BLOOM_HEADLESS")
504
+ .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
505
+ .unwrap_or(false);
506
+ // BLOOM_NO_FULLSCREEN=1 hard-disables fullscreen capability for
507
+ // benchmark harnesses where a 4K-display fullscreen path would
508
+ // silently quadruple render cost.
509
+ let no_fullscreen = std::env::var("BLOOM_NO_FULLSCREEN")
510
+ .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
511
+ .unwrap_or(false);
512
+ x11_impl::set_no_fullscreen(no_fullscreen);
513
+
514
+ let (phys_w, phys_h) = x11_impl::create_window(width, height, title, headless);
260
515
 
261
516
  let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
262
517
  backends: wgpu::Backends::VULKAN | wgpu::Backends::GL,
@@ -308,6 +563,10 @@ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u
308
563
  wgpu::ExperimentalFeatures::disabled()
309
564
  };
310
565
  let mut required_limits = wgpu::Limits::default();
566
+ // The material ABI declares 5 bind groups (PerFrame, PerView,
567
+ // PerMaterial, PerDraw, SceneInputs). wgpu's default limit is
568
+ // 4. Vulkan supports at least 7 here, so 5 is universally safe.
569
+ required_limits.max_bind_groups = 5;
311
570
  if required_features.intersects(rt_mask) {
312
571
  required_limits = required_limits
313
572
  .using_minimum_supported_acceleration_structure_values();
@@ -346,6 +605,12 @@ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u
346
605
  if fullscreen != 0.0 {
347
606
  x11_impl::set_fullscreen(true);
348
607
  }
608
+
609
+ // Register Bloom's GPU screenshot capture with perry-geisterhand.
610
+ // perry-runtime always exposes these symbols, so the link is direct
611
+ // (not weak); the registry no-ops gracefully if the editor isn't
612
+ // running.
613
+ bloom_register_geisterhand_screenshot();
349
614
  }
350
615
 
351
616
  #[cfg(not(target_os = "linux"))]
@@ -418,12 +683,23 @@ pub extern "C" fn bloom_begin_drawing() {
418
683
  {
419
684
  x11_impl::poll_events();
420
685
  poll_linux_gamepad();
686
+ // Apply Q2 cursor shape — mirrors macOS NSCursor calls in its
687
+ // event loop. set_cursor_shape just stores the value; the X11
688
+ // cursor only changes when we actually call XDefineCursor.
689
+ x11_impl::apply_cursor_shape(engine().input.cursor_shape);
421
690
  }
422
691
  engine().begin_frame();
423
692
  }
424
693
 
425
694
  #[no_mangle]
426
- pub extern "C" fn bloom_end_drawing() { engine().end_frame(); }
695
+ pub extern "C" fn bloom_end_drawing() {
696
+ // Pump geisterhand BEFORE end_frame. Mirrors macOS — the screenshot
697
+ // function re-renders inline using the captured VP + vertex buffers.
698
+ extern "C" { fn perry_geisterhand_pump(); }
699
+ unsafe { perry_geisterhand_pump(); }
700
+
701
+ engine().end_frame();
702
+ }
427
703
 
428
704
  #[no_mangle]
429
705
  pub extern "C" fn bloom_clear_background(r: f64, g: f64, b: f64, a: f64) {
@@ -914,13 +1190,27 @@ pub extern "C" fn bloom_load_shader(source_ptr: *const u8) -> f64 {
914
1190
  }
915
1191
 
916
1192
  #[no_mangle]
917
- pub extern "C" fn bloom_create_mesh(vertex_ptr: *const f32, vertex_count: f64, index_ptr: *const u32, index_count: f64) -> f64 {
1193
+ pub extern "C" fn bloom_create_mesh(vertex_ptr: *const f64, vertex_count: f64, index_ptr: *const f64, index_count: f64) -> f64 {
1194
+ // Perry's TS `number[]` is f64-laid-out in memory; Perry passes a
1195
+ // pointer to that data. A previous version of this FFI declared
1196
+ // *const f32 / *const u32, which silently read the low 4 bytes
1197
+ // of each f64 slot as garbage f32/u32 values — meshes registered
1198
+ // (non-zero handle) but were unrenderable.
1199
+ //
1200
+ // Caller must pass `vertex_count` and `index_count` derived from
1201
+ // a literal-initialized array OR from values it computed itself.
1202
+ // Don't compute these via `arr.length` after `.push()` — Perry's
1203
+ // `.length` property currently reflects the literal-init size,
1204
+ // not the post-push count (a Perry codegen bug). Workaround on
1205
+ // the TS side: track the count manually or use literal arrays.
918
1206
  if vertex_ptr.is_null() || index_ptr.is_null() { return 0.0; }
919
1207
  let vcount = vertex_count as usize;
920
1208
  let icount = index_count as usize;
921
- let vertex_data = unsafe { std::slice::from_raw_parts(vertex_ptr, vcount * 12) }; // 12 floats per vertex
922
- let index_data = unsafe { std::slice::from_raw_parts(index_ptr, icount) };
923
- engine().models.create_mesh(vertex_data, index_data)
1209
+ let vertex_f64 = unsafe { std::slice::from_raw_parts(vertex_ptr, vcount * 12) };
1210
+ let index_f64 = unsafe { std::slice::from_raw_parts(index_ptr, icount) };
1211
+ let vertex_data: Vec<f32> = vertex_f64.iter().map(|&v| v as f32).collect();
1212
+ let index_data: Vec<u32> = index_f64.iter().map(|&v| v as u32).collect();
1213
+ engine().models.create_mesh(&vertex_data, &index_data)
924
1214
  }
925
1215
 
926
1216
  // ============================================================
@@ -1261,12 +1551,21 @@ pub extern "C" fn bloom_load_model_animation(path_ptr: *const u8) -> f64 {
1261
1551
  }
1262
1552
 
1263
1553
  #[no_mangle]
1264
- pub extern "C" fn bloom_update_model_animation(handle: f64, anim_index: f64, time: f64, scale: f64, px: f64, py: f64, pz: f64, rot_sin: f64, rot_cos: f64) {
1554
+ pub extern "C" fn bloom_update_model_animation(handle: f64, anim_index: f64, time: f64, scale: f64, px: f64, py: f64, pz: f64, rot_y: f64) {
1555
+ // Take a single Y-axis angle (radians) instead of sin/cos, so the
1556
+ // engine reconstructs both with full precision + correct signs.
1557
+ // Older callers that passed (rot_sin, rot_cos) hit a Perry-ARM64
1558
+ // 9th-arg garbling bug AND a sqrt(1-sin²) workaround that lost
1559
+ // the sign of cos — model rotation was correct only on half the
1560
+ // circle. 8-arg signature dodges both issues. Matches macOS.
1561
+ let rot_y_f = rot_y as f32;
1562
+ let rot_sin = rot_y_f.sin();
1563
+ let rot_cos = rot_y_f.cos();
1265
1564
  let eng = engine();
1266
1565
  eng.models.update_model_animation(handle, anim_index as usize, time as f32);
1267
1566
  if let Some(anim) = eng.models.get_animation(handle) {
1268
1567
  if !anim.joint_matrices.is_empty() {
1269
- eng.renderer.set_joint_matrices_scaled(&anim.joint_matrices, scale as f32, [px as f32, py as f32, pz as f32], rot_sin as f32, rot_cos as f32);
1568
+ eng.renderer.set_joint_matrices_scaled(&anim.joint_matrices, scale as f32, [px as f32, py as f32, pz as f32], rot_sin, rot_cos);
1270
1569
  }
1271
1570
  }
1272
1571
  }
@@ -1354,18 +1653,32 @@ pub extern "C" fn bloom_toggle_fullscreen() {
1354
1653
  x11_impl::toggle_fullscreen();
1355
1654
  }
1356
1655
  #[no_mangle]
1357
- pub extern "C" fn bloom_set_window_title(title_ptr: *const u8) { let _ = str_from_header(title_ptr); }
1656
+ pub extern "C" fn bloom_set_window_title(title_ptr: *const u8) {
1657
+ let title = str_from_header(title_ptr);
1658
+ #[cfg(target_os = "linux")]
1659
+ x11_impl::set_window_title(title);
1660
+ }
1358
1661
  #[no_mangle]
1359
- pub extern "C" fn bloom_set_window_icon(path_ptr: *const u8) { let _ = str_from_header(path_ptr); }
1662
+ pub extern "C" fn bloom_set_window_icon(path_ptr: *const u8) {
1663
+ let path = str_from_header(path_ptr);
1664
+ #[cfg(target_os = "linux")]
1665
+ x11_impl::set_window_icon(path);
1666
+ }
1360
1667
 
1361
1668
  #[no_mangle]
1362
1669
  pub extern "C" fn bloom_disable_cursor() {
1363
- engine().input.cursor_disabled = true;
1670
+ let input = &mut engine().input;
1671
+ input.cursor_disabled = true;
1672
+ input.clear_mouse_delta();
1673
+ #[cfg(target_os = "linux")]
1674
+ x11_impl::enter_relative_mode();
1364
1675
  }
1365
1676
 
1366
1677
  #[no_mangle]
1367
1678
  pub extern "C" fn bloom_enable_cursor() {
1368
1679
  engine().input.cursor_disabled = false;
1680
+ #[cfg(target_os = "linux")]
1681
+ x11_impl::leave_relative_mode();
1369
1682
  }
1370
1683
 
1371
1684
  #[no_mangle]
@@ -1397,17 +1710,51 @@ pub extern "C" fn bloom_set_cursor_shape(shape: f64) {
1397
1710
  engine().input.cursor_shape = shape as u32;
1398
1711
  }
1399
1712
 
1400
- // E4: Clipboard (stub on this platform)
1713
+ // E4: Clipboard (arboard, X11/Wayland-aware)
1401
1714
  #[no_mangle]
1402
- pub extern "C" fn bloom_set_clipboard_text(_text_ptr: *const u8) {}
1715
+ pub extern "C" fn bloom_set_clipboard_text(text_ptr: *const u8) {
1716
+ let text = str_from_header(text_ptr);
1717
+ if let Ok(mut clipboard) = arboard::Clipboard::new() {
1718
+ let _ = clipboard.set_text(text.to_string());
1719
+ }
1720
+ }
1403
1721
  #[no_mangle]
1404
- pub extern "C" fn bloom_get_clipboard_text() -> *const u8 { std::ptr::null() }
1722
+ pub extern "C" fn bloom_get_clipboard_text() -> *const u8 {
1723
+ match arboard::Clipboard::new() {
1724
+ Ok(mut clipboard) => match clipboard.get_text() {
1725
+ Ok(text) => alloc_perry_string(&text),
1726
+ Err(_) => alloc_perry_string(""),
1727
+ },
1728
+ Err(_) => alloc_perry_string(""),
1729
+ }
1730
+ }
1405
1731
 
1406
- // E5b: File dialogs (stub on this platform)
1732
+ // E5b: Native file dialogs (rfd → GTK/zenity/kdialog on Linux)
1407
1733
  #[no_mangle]
1408
- pub extern "C" fn bloom_open_file_dialog(_filter_ptr: *const u8, _title_ptr: *const u8) -> *const u8 { std::ptr::null() }
1734
+ pub extern "C" fn bloom_open_file_dialog(filter_ptr: *const u8, title_ptr: *const u8) -> *const u8 {
1735
+ let filter = str_from_header(filter_ptr);
1736
+ let title = str_from_header(title_ptr);
1737
+ let mut dialog = rfd::FileDialog::new().set_title(title);
1738
+ if !filter.is_empty() {
1739
+ dialog = dialog.add_filter("Files", &[filter]);
1740
+ }
1741
+ match dialog.pick_file() {
1742
+ Some(path) => alloc_perry_string(&path.to_string_lossy()),
1743
+ None => alloc_perry_string(""),
1744
+ }
1745
+ }
1409
1746
  #[no_mangle]
1410
- pub extern "C" fn bloom_save_file_dialog(_default_name_ptr: *const u8, _title_ptr: *const u8) -> *const u8 { std::ptr::null() }
1747
+ pub extern "C" fn bloom_save_file_dialog(default_name_ptr: *const u8, title_ptr: *const u8) -> *const u8 {
1748
+ let default_name = str_from_header(default_name_ptr);
1749
+ let title = str_from_header(title_ptr);
1750
+ let dialog = rfd::FileDialog::new()
1751
+ .set_title(title)
1752
+ .set_file_name(default_name);
1753
+ match dialog.save_file() {
1754
+ Some(path) => alloc_perry_string(&path.to_string_lossy()),
1755
+ None => alloc_perry_string(""),
1756
+ }
1757
+ }
1411
1758
 
1412
1759
  // Model bounds accessors. Return the axis-aligned bounding box of a loaded
1413
1760
  // model in model-local coordinates. Editors use these to size gizmos, auto-
@@ -1456,24 +1803,14 @@ pub extern "C" fn bloom_file_exists(path_ptr: *const u8) -> f64 {
1456
1803
  #[no_mangle]
1457
1804
  pub extern "C" fn bloom_read_file(path_ptr: *const u8) -> *const u8 {
1458
1805
  let path = str_from_header(path_ptr);
1806
+ // Always return a valid Perry string. A null pointer would NaN-box into a
1807
+ // string-typed JS value pointing at address 0; subsequent `.length` /
1808
+ // `.charCodeAt` reads dereference the bogus StringHeader and segfault.
1809
+ // Callers detect "missing file" via `data.length === 0` (e.g. the
1810
+ // jump game's discoverLevels probe across level1..level10 / custom_*).
1459
1811
  match std::fs::read_to_string(path) {
1460
- Ok(contents) => {
1461
- // Return Perry-format string: StringHeader (length u32 + capacity u32 + refcount u32) followed by UTF-8 data
1462
- let bytes = contents.as_bytes();
1463
- let len = bytes.len();
1464
- let total = 12 + len; // 12 bytes header (3 × u32) + data
1465
- let layout = std::alloc::Layout::from_size_align(total, 4).unwrap();
1466
- unsafe {
1467
- let ptr = std::alloc::alloc(layout);
1468
- if ptr.is_null() { return std::ptr::null(); }
1469
- *(ptr as *mut u32) = len as u32; // length
1470
- *(ptr.add(4) as *mut u32) = len as u32; // capacity
1471
- *(ptr.add(8) as *mut u32) = 1; // refcount (unique)
1472
- std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr.add(12), len);
1473
- ptr
1474
- }
1475
- }
1476
- Err(_) => std::ptr::null(),
1812
+ Ok(contents) => alloc_perry_string(&contents),
1813
+ Err(_) => alloc_perry_string(""),
1477
1814
  }
1478
1815
  }
1479
1816
 
@@ -1802,6 +2139,12 @@ pub extern "C" fn bloom_disable_shadows() {
1802
2139
  engine().renderer.shadow_map.disable();
1803
2140
  }
1804
2141
 
2142
+ #[no_mangle]
2143
+ pub extern "C" fn bloom_dump_shadow_map(path_ptr: *const u8) {
2144
+ let path = str_from_header(path_ptr).to_string();
2145
+ engine().renderer.dump_shadow_map(&path);
2146
+ }
2147
+
1805
2148
  // ============================================================
1806
2149
  // Post-processing
1807
2150
  // ============================================================
@@ -2200,6 +2543,197 @@ pub extern "C" fn bloom_set_sss_enabled(on: f64) {
2200
2543
  engine().renderer.set_sss_enabled(on != 0.0);
2201
2544
  }
2202
2545
 
2546
+ // ============================================================
2547
+ // Render scale / upscale / DRS / post-FX / screenshots / impulse
2548
+ // Ports of the macOS FFI surface so the bloom/core TS layer links
2549
+ // cleanly on Linux. EngineState in bloom-shared already exposes the
2550
+ // underlying renderer methods, so these wrappers are platform-agnostic.
2551
+ // ============================================================
2552
+
2553
+ #[no_mangle]
2554
+ pub extern "C" fn bloom_take_screenshot(path_ptr: *const u8) {
2555
+ let path = str_from_header(path_ptr).to_string();
2556
+ let eng = engine();
2557
+ eng.renderer.screenshot_requested = true;
2558
+ eng.renderer.pending_screenshot_path = Some(path);
2559
+ }
2560
+
2561
+ #[no_mangle]
2562
+ pub extern "C" fn bloom_set_env_clear_from_hdr(path_ptr: *const u8) {
2563
+ use image::ImageDecoder;
2564
+ let path = str_from_header(path_ptr).to_string();
2565
+ let file = match std::fs::File::open(&path) {
2566
+ Ok(f) => f,
2567
+ Err(_) => return,
2568
+ };
2569
+ let decoder = match image::codecs::hdr::HdrDecoder::new(std::io::BufReader::new(file)) {
2570
+ Ok(d) => d,
2571
+ Err(_) => return,
2572
+ };
2573
+ let (w, h) = decoder.dimensions();
2574
+ let byte_len = (w as usize) * (h as usize) * 3 * 4;
2575
+ let mut buf = vec![0u8; byte_len];
2576
+ if decoder.read_image(&mut buf).is_err() {
2577
+ return;
2578
+ }
2579
+ let rgb_f32: Vec<f32> = buf
2580
+ .chunks_exact(4)
2581
+ .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
2582
+ .collect();
2583
+ engine().renderer.load_env_from_hdr(w, h, &rgb_f32);
2584
+ }
2585
+
2586
+ #[no_mangle]
2587
+ pub extern "C" fn bloom_set_fog(r: f64, g: f64, b: f64, density: f64, height_ref: f64, height_falloff: f64) {
2588
+ let r_ = engine();
2589
+ r_.renderer.set_fog_color(r as f32, g as f32, b as f32);
2590
+ r_.renderer.set_fog_density(density as f32);
2591
+ r_.renderer.set_fog_height_falloff(height_ref as f32, height_falloff as f32);
2592
+ }
2593
+
2594
+ #[no_mangle]
2595
+ pub extern "C" fn bloom_set_chromatic_aberration(strength: f64) {
2596
+ engine().renderer.set_chromatic_aberration(strength as f32);
2597
+ }
2598
+
2599
+ #[no_mangle]
2600
+ pub extern "C" fn bloom_set_vignette(strength: f64, softness: f64) {
2601
+ engine().renderer.set_vignette(strength as f32, softness as f32);
2602
+ }
2603
+
2604
+ #[no_mangle]
2605
+ pub extern "C" fn bloom_set_film_grain(strength: f64) {
2606
+ engine().renderer.set_film_grain(strength as f32);
2607
+ }
2608
+
2609
+ #[no_mangle]
2610
+ pub extern "C" fn bloom_set_sun_shafts(strength: f64, decay: f64, r: f64, g: f64, b: f64) {
2611
+ let eng = engine();
2612
+ eng.renderer.set_sun_shaft_strength(strength as f32);
2613
+ eng.renderer.set_sun_shaft_decay(decay as f32);
2614
+ eng.renderer.set_sun_shaft_color(r as f32, g as f32, b as f32);
2615
+ }
2616
+
2617
+ #[no_mangle]
2618
+ pub extern "C" fn bloom_set_auto_exposure(on: f64) {
2619
+ engine().renderer.set_auto_exposure(on != 0.0);
2620
+ }
2621
+
2622
+ #[no_mangle]
2623
+ pub extern "C" fn bloom_set_taa_enabled(on: f64) {
2624
+ engine().renderer.set_taa_enabled(on != 0.0);
2625
+ }
2626
+
2627
+ #[no_mangle]
2628
+ pub extern "C" fn bloom_set_render_scale(scale: f64) {
2629
+ engine().renderer.set_render_scale(scale as f32);
2630
+ }
2631
+
2632
+ #[no_mangle]
2633
+ pub extern "C" fn bloom_get_render_scale() -> f64 {
2634
+ engine().renderer.render_scale() as f64
2635
+ }
2636
+
2637
+ #[no_mangle]
2638
+ pub extern "C" fn bloom_set_upscale_mode(mode: f64) {
2639
+ engine().renderer.set_upscale_mode(mode as u32);
2640
+ }
2641
+
2642
+ #[no_mangle]
2643
+ pub extern "C" fn bloom_set_cas_strength(strength: f64) {
2644
+ engine().renderer.set_cas_strength(strength as f32);
2645
+ }
2646
+
2647
+ #[no_mangle]
2648
+ pub extern "C" fn bloom_get_physical_width() -> f64 {
2649
+ engine().renderer.physical_width() as f64
2650
+ }
2651
+
2652
+ #[no_mangle]
2653
+ pub extern "C" fn bloom_get_physical_height() -> f64 {
2654
+ engine().renderer.physical_height() as f64
2655
+ }
2656
+
2657
+ #[no_mangle]
2658
+ pub extern "C" fn bloom_set_auto_resolution(target_hz: f64, enabled: f64) {
2659
+ let eng = engine();
2660
+ if enabled != 0.0 {
2661
+ let current = eng.renderer.render_scale();
2662
+ eng.drs.enable(target_hz as f32, current);
2663
+ } else {
2664
+ eng.drs.disable();
2665
+ }
2666
+ }
2667
+
2668
+ #[no_mangle]
2669
+ pub extern "C" fn bloom_set_manual_exposure(value: f64) {
2670
+ engine().renderer.set_manual_exposure(value as f32);
2671
+ }
2672
+
2673
+ #[no_mangle]
2674
+ pub extern "C" fn bloom_set_env_intensity(intensity: f64) {
2675
+ engine().renderer.set_env_intensity(intensity as f32);
2676
+ }
2677
+
2678
+ #[no_mangle]
2679
+ pub extern "C" fn bloom_set_ssgi_enabled(enabled: f64) {
2680
+ engine().renderer.set_ssgi_enabled(enabled != 0.0);
2681
+ }
2682
+
2683
+ #[no_mangle]
2684
+ pub extern "C" fn bloom_set_ssgi_intensity(intensity: f64) {
2685
+ engine().renderer.set_ssgi_intensity(intensity as f32);
2686
+ }
2687
+
2688
+ #[no_mangle]
2689
+ pub extern "C" fn bloom_set_ssgi_radius(radius: f64) {
2690
+ engine().renderer.set_ssgi_radius(radius as f32);
2691
+ }
2692
+
2693
+ #[no_mangle]
2694
+ pub extern "C" fn bloom_set_dof(enabled: f64, focus_distance: f64, aperture: f64) {
2695
+ let r = &mut engine().renderer;
2696
+ r.set_dof_enabled(enabled != 0.0);
2697
+ r.set_dof_focus_distance(focus_distance as f32);
2698
+ r.set_dof_aperture(aperture as f32);
2699
+ }
2700
+
2701
+ #[no_mangle]
2702
+ pub extern "C" fn bloom_splat_impulse(x: f64, z: f64, radius: f64, strength: f64) {
2703
+ engine().renderer.impulse_field.submit_splat(
2704
+ x as f32, z as f32, radius as f32, strength as f32,
2705
+ );
2706
+ }
2707
+
2708
+ #[no_mangle]
2709
+ pub extern "C" fn bloom_profiler_frame_history() -> *const u8 {
2710
+ let hist = engine().profiler.frame_history();
2711
+ let mut s = String::with_capacity(hist.len() * 24);
2712
+ for (cpu, gpu) in &hist {
2713
+ s.push_str(&format!("{:.2}|{:.2}\n", cpu, gpu));
2714
+ }
2715
+ alloc_perry_string(&s)
2716
+ }
2717
+
2718
+ #[no_mangle]
2719
+ pub extern "C" fn bloom_profiler_overlay_text() -> *const u8 {
2720
+ let snap = engine().profiler.snapshot();
2721
+ let mut s = String::with_capacity(snap.len() * 48);
2722
+ for (label, cpu, gpu) in &snap {
2723
+ s.push_str(label);
2724
+ s.push('|');
2725
+ s.push_str(&format!("{:.2}", cpu));
2726
+ s.push('|');
2727
+ match gpu {
2728
+ Some(g) => s.push_str(&format!("{:.2}", g)),
2729
+ None => s.push_str("-1"),
2730
+ }
2731
+ s.push('\n');
2732
+ }
2733
+ alloc_perry_string(&s)
2734
+ }
2735
+
2736
+
2203
2737
  // ============================================================
2204
2738
  // Profiler — CPU phase timings (always available) + GPU timestamps
2205
2739
  // (when the adapter supports TIMESTAMP_QUERY). Disabled by default.
@@ -2222,6 +2756,177 @@ pub extern "C" fn bloom_print_profiler_summary() {
2222
2756
  print!("{}", engine().profiler.summary());
2223
2757
  }
2224
2758
 
2759
+ // ============================================================
2760
+ // Geisterhand screenshot integration
2761
+ // ============================================================
2762
+
2763
+ /// Register Bloom's GPU-based screenshot capture with perry-geisterhand.
2764
+ /// Mirrors macOS — replaces the platform-default window-grabber with a
2765
+ /// direct wgpu texture readback that works against the same Vulkan/GL
2766
+ /// surface bloom is already drawing into.
2767
+ fn bloom_register_geisterhand_screenshot() {
2768
+ extern "C" {
2769
+ fn perry_geisterhand_register_screenshot_capture(
2770
+ f: extern "C" fn(*mut usize) -> *mut u8,
2771
+ );
2772
+ }
2773
+ unsafe {
2774
+ perry_geisterhand_register_screenshot_capture(bloom_screenshot_capture);
2775
+ }
2776
+ }
2777
+
2778
+ /// Capture the Bloom framebuffer as PNG. Called from the geisterhand pump
2779
+ /// BEFORE end_frame in bloom_end_drawing. The vertices_3d/2d and VP matrix
2780
+ /// from the game loop are still populated; we render to a fresh surface
2781
+ /// texture with screenshot capture, producing the same visual output as
2782
+ /// the real frame.
2783
+ extern "C" fn bloom_screenshot_capture(out_len: *mut usize) -> *mut u8 {
2784
+ let eng = engine();
2785
+
2786
+ eng.renderer.screenshot_requested = true;
2787
+ eng.scene.prepare(
2788
+ &eng.renderer.device,
2789
+ &eng.renderer.queue,
2790
+ &eng.renderer.vp_matrix(),
2791
+ &eng.renderer.prev_vp_matrix,
2792
+ eng.renderer.uniform_3d_layout(),
2793
+ );
2794
+ eng.scene.prepare_materials(&eng.renderer);
2795
+ {
2796
+ let t = eng.get_time() as f32;
2797
+ let dt = eng.delta_time as f32;
2798
+ eng.renderer.material_system_begin_frame(t, dt);
2799
+ }
2800
+ eng.renderer.end_frame_with_scene(&mut eng.scene, &mut eng.profiler);
2801
+
2802
+ match eng.renderer.screenshot_data.take() {
2803
+ Some((width, height, rgba)) => {
2804
+ match encode_png(width, height, &rgba) {
2805
+ Some(png_data) => {
2806
+ let len = png_data.len();
2807
+ let ptr = unsafe { libc::malloc(len) as *mut u8 };
2808
+ if ptr.is_null() {
2809
+ unsafe { *out_len = 0; }
2810
+ return std::ptr::null_mut();
2811
+ }
2812
+ unsafe {
2813
+ std::ptr::copy_nonoverlapping(png_data.as_ptr(), ptr, len);
2814
+ *out_len = len;
2815
+ }
2816
+ ptr
2817
+ }
2818
+ None => {
2819
+ unsafe { *out_len = 0; }
2820
+ std::ptr::null_mut()
2821
+ }
2822
+ }
2823
+ }
2824
+ None => {
2825
+ unsafe { *out_len = 0; }
2826
+ std::ptr::null_mut()
2827
+ }
2828
+ }
2829
+ }
2830
+
2831
+ /// Minimal PNG encoder (no external dependency). Matches the macOS
2832
+ /// implementation byte-for-byte so screenshots are identical across
2833
+ /// platforms.
2834
+ fn encode_png(width: u32, height: u32, rgba: &[u8]) -> Option<Vec<u8>> {
2835
+ use std::io::Write;
2836
+
2837
+ let mut png = Vec::new();
2838
+ png.write_all(&[137, 80, 78, 71, 13, 10, 26, 10]).ok()?;
2839
+
2840
+ let mut ihdr = Vec::new();
2841
+ ihdr.extend_from_slice(&width.to_be_bytes());
2842
+ ihdr.extend_from_slice(&height.to_be_bytes());
2843
+ ihdr.push(8);
2844
+ ihdr.push(6);
2845
+ ihdr.push(0);
2846
+ ihdr.push(0);
2847
+ ihdr.push(0);
2848
+ write_png_chunk(&mut png, b"IHDR", &ihdr);
2849
+
2850
+ let row_bytes = (width * 4) as usize;
2851
+ let mut raw = Vec::with_capacity((row_bytes + 1) * height as usize);
2852
+ for y in 0..height as usize {
2853
+ raw.push(0);
2854
+ let start = y * row_bytes;
2855
+ for x in 0..width as usize {
2856
+ let idx = start + x * 4;
2857
+ // wgpu Bgra8UnormSrgb: byte order is B, G, R, A
2858
+ raw.push(rgba[idx + 2]);
2859
+ raw.push(rgba[idx + 1]);
2860
+ raw.push(rgba[idx + 0]);
2861
+ raw.push(255);
2862
+ }
2863
+ }
2864
+
2865
+ let deflated = deflate_store(&raw);
2866
+ write_png_chunk(&mut png, b"IDAT", &deflated);
2867
+ write_png_chunk(&mut png, b"IEND", &[]);
2868
+ Some(png)
2869
+ }
2870
+
2871
+ fn write_png_chunk(out: &mut Vec<u8>, chunk_type: &[u8; 4], data: &[u8]) {
2872
+ let len = data.len() as u32;
2873
+ out.extend_from_slice(&len.to_be_bytes());
2874
+ out.extend_from_slice(chunk_type);
2875
+ out.extend_from_slice(data);
2876
+ let crc = crc32(&[chunk_type.as_slice(), data].concat());
2877
+ out.extend_from_slice(&crc.to_be_bytes());
2878
+ }
2879
+
2880
+ fn crc32(data: &[u8]) -> u32 {
2881
+ let mut crc: u32 = 0xFFFFFFFF;
2882
+ for &byte in data {
2883
+ crc ^= byte as u32;
2884
+ for _ in 0..8 {
2885
+ if crc & 1 != 0 {
2886
+ crc = (crc >> 1) ^ 0xEDB88320;
2887
+ } else {
2888
+ crc >>= 1;
2889
+ }
2890
+ }
2891
+ }
2892
+ !crc
2893
+ }
2894
+
2895
+ fn deflate_store(data: &[u8]) -> Vec<u8> {
2896
+ let mut out = Vec::new();
2897
+ out.push(0x78);
2898
+ out.push(0x01);
2899
+
2900
+ let mut remaining = data.len();
2901
+ let mut offset = 0;
2902
+ while remaining > 0 {
2903
+ let block_size = remaining.min(65535);
2904
+ let is_last = remaining <= 65535;
2905
+ out.push(if is_last { 1 } else { 0 });
2906
+ let len = block_size as u16;
2907
+ let nlen = !len;
2908
+ out.extend_from_slice(&len.to_le_bytes());
2909
+ out.extend_from_slice(&nlen.to_le_bytes());
2910
+ out.extend_from_slice(&data[offset..offset + block_size]);
2911
+ offset += block_size;
2912
+ remaining -= block_size;
2913
+ }
2914
+
2915
+ let adler = adler32(data);
2916
+ out.extend_from_slice(&adler.to_be_bytes());
2917
+ out
2918
+ }
2919
+
2920
+ fn adler32(data: &[u8]) -> u32 {
2921
+ let mut a: u32 = 1;
2922
+ let mut b: u32 = 0;
2923
+ for &byte in data {
2924
+ a = (a + byte as u32) % 65521;
2925
+ b = (b + a) % 65521;
2926
+ }
2927
+ (b << 16) | a
2928
+ }
2929
+
2225
2930
  // ============================================================
2226
2931
  // Physics (Jolt 5.x) — FFI surface generated from shared macro
2227
2932
  // ============================================================