@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,1331 @@
1
+ use bloom_shared::engine::EngineState;
2
+ use bloom_shared::renderer::Renderer;
3
+ use bloom_shared::string_header::{alloc_perry_string, str_from_header};
4
+
5
+ use std::os::unix::io::RawFd;
6
+ #[cfg(feature = "jolt")]
7
+ use std::ops::{Deref, DerefMut};
8
+ use std::sync::{Mutex, MutexGuard, OnceLock};
9
+
10
+ static ENGINE: OnceLock<Mutex<EngineState>> = OnceLock::new();
11
+ // X11 and joystick state below are confined to Perry's main run-loop thread.
12
+ // EngineState uses a Mutex because FFI callbacks can re-enter the engine;
13
+ // these platform handles are not accessed from worker threads.
14
+ static mut GAMEPAD_FD: RawFd = -1;
15
+
16
+ fn engine() -> MutexGuard<'static, EngineState> {
17
+ ENGINE
18
+ .get()
19
+ .expect("Engine not initialized")
20
+ .lock()
21
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
22
+ }
23
+ /// Asset-path hook for define_core_ffi! — identity on desktop, where game
24
+ /// asset paths are valid relative to the working directory.
25
+ fn bloom_resolve_asset_path(path: &str) -> std::borrow::Cow<'_, str> {
26
+ std::borrow::Cow::Borrowed(path)
27
+ }
28
+
29
+ // The full shared (non-physics) FFI surface. See bloom_shared::ffi_core
30
+ // docs for the contract; tools/validate-ffi.js checks parity in CI.
31
+ bloom_shared::define_core_ffi!();
32
+
33
+ /// Map X11 keysym to Bloom key code.
34
+ fn map_keycode(keysym: u32) -> usize {
35
+ match keysym {
36
+ 0x61..=0x7a => (keysym - 0x61 + 65) as usize, // a-z → A-Z (65-90)
37
+ 0x41..=0x5a => keysym as usize, // A-Z direct
38
+ 0x30..=0x39 => keysym as usize, // 0-9
39
+ 0xff52 => 256, // XK_Up
40
+ 0xff54 => 257, // XK_Down
41
+ 0xff51 => 258, // XK_Left
42
+ 0xff53 => 259, // XK_Right
43
+ 0x20 => 32, // space
44
+ 0xff0d => 265, // XK_Return → Bloom ENTER
45
+ 0xff1b => 27, // XK_Escape
46
+ 0xff09 => 9, // XK_Tab
47
+ 0xff08 => 8, // XK_BackSpace
48
+ 0xffff => 127, // XK_Delete
49
+ 0xff63 => 260, // XK_Insert
50
+ 0xff50 => 261, // XK_Home
51
+ 0xff57 => 262, // XK_End
52
+ 0xff55 => 263, // XK_Page_Up
53
+ 0xff56 => 264, // XK_Page_Down
54
+ 0xffe1 => 280, // XK_Shift_L
55
+ 0xffe2 => 281, // XK_Shift_R
56
+ 0xffe3 => 282, // XK_Control_L
57
+ 0xffe4 => 283, // XK_Control_R
58
+ 0xffe9 => 284, // XK_Alt_L
59
+ 0xffea => 285, // XK_Alt_R
60
+ 0xffeb => 286, // XK_Super_L
61
+ 0xffec => 287, // XK_Super_R
62
+ // Function keys
63
+ 0xffbe => 112, // XK_F1
64
+ 0xffbf => 113, // XK_F2
65
+ 0xffc0 => 114, // XK_F3
66
+ 0xffc1 => 115, // XK_F4
67
+ 0xffc2 => 116, // XK_F5
68
+ 0xffc3 => 117, // XK_F6
69
+ 0xffc4 => 118, // XK_F7
70
+ 0xffc5 => 119, // XK_F8
71
+ 0xffc6 => 120, // XK_F9
72
+ 0xffc7 => 121, // XK_F10
73
+ 0xffc8 => 122, // XK_F11
74
+ 0xffc9 => 123, // XK_F12
75
+ _ => 0,
76
+ }
77
+ }
78
+
79
+ #[cfg(target_os = "linux")]
80
+ mod x11_impl {
81
+ use super::*;
82
+
83
+ static mut DISPLAY: *mut x11::xlib::Display = std::ptr::null_mut();
84
+ static mut X11_WINDOW: x11::xlib::Window = 0;
85
+ static mut IS_FULLSCREEN: bool = false;
86
+ static mut NO_FULLSCREEN: bool = false;
87
+ static mut CURSOR_HIDDEN: bool = false;
88
+ static mut HIDDEN_CURSOR: x11::xlib::Cursor = 0;
89
+ /// Cached XC_* shape cursors keyed by `cursor_shape` value 0..=6.
90
+ /// Lazily created by `apply_cursor_shape`; reused across frames so
91
+ /// we don't leak a Cursor handle every poll.
92
+ struct CursorCache {
93
+ cursors: [x11::xlib::Cursor; 8],
94
+ last_applied_shape: u32,
95
+ }
96
+ static CURSOR_CACHE: Mutex<CursorCache> = Mutex::new(CursorCache {
97
+ cursors: [0; 8],
98
+ last_applied_shape: 0xFFFF_FFFF,
99
+ });
100
+ /// When `cursor_disabled` (relative-mode) is on we keep warping the
101
+ /// pointer back to window center each frame; remembering the last warp
102
+ /// target lets motion handlers compute a reliable raw delta and ignore
103
+ /// the synthetic motion event the warp itself generates.
104
+ static mut WARP_CENTER_X: i32 = 0;
105
+ static mut WARP_CENTER_Y: i32 = 0;
106
+ static mut RELATIVE_MODE: bool = false;
107
+ /// WM_PROTOCOLS / WM_DELETE_WINDOW, interned once in `create_window`.
108
+ /// Cached because `poll_events` compares against them every event.
109
+ static mut WM_PROTOCOLS: x11::xlib::Atom = 0;
110
+ static mut WM_DELETE_WINDOW: x11::xlib::Atom = 0;
111
+
112
+ pub fn wm_protocols_atom() -> x11::xlib::Atom { unsafe { WM_PROTOCOLS } }
113
+ pub fn wm_delete_window_atom() -> x11::xlib::Atom { unsafe { WM_DELETE_WINDOW } }
114
+
115
+ pub fn set_fullscreen(fullscreen: bool) {
116
+ unsafe {
117
+ // BLOOM_NO_FULLSCREEN=1 hard-disables the fullscreen path so
118
+ // benchmark harnesses on a 4K monitor don't silently 4× their
119
+ // pixel count when an inherited fullscreen Space leaks in.
120
+ if NO_FULLSCREEN && fullscreen { return; }
121
+ if DISPLAY.is_null() || X11_WINDOW == 0 { return; }
122
+
123
+ let wm_state = x11::xlib::XInternAtom(
124
+ DISPLAY,
125
+ b"_NET_WM_STATE\0".as_ptr() as *const _,
126
+ 0,
127
+ );
128
+ let wm_fullscreen = x11::xlib::XInternAtom(
129
+ DISPLAY,
130
+ b"_NET_WM_STATE_FULLSCREEN\0".as_ptr() as *const _,
131
+ 0,
132
+ );
133
+
134
+ let action = if fullscreen { 1 } else { 0 }; // _NET_WM_STATE_ADD / _REMOVE
135
+
136
+ let mut event: x11::xlib::XClientMessageEvent = std::mem::zeroed();
137
+ event.type_ = x11::xlib::ClientMessage;
138
+ event.window = X11_WINDOW;
139
+ event.message_type = wm_state;
140
+ event.format = 32;
141
+ event.data.set_long(0, action);
142
+ event.data.set_long(1, wm_fullscreen as i64);
143
+ event.data.set_long(2, 0);
144
+ event.data.set_long(3, 1); // source: normal application
145
+
146
+ let root = x11::xlib::XDefaultRootWindow(DISPLAY);
147
+ x11::xlib::XSendEvent(
148
+ DISPLAY,
149
+ root,
150
+ 0,
151
+ x11::xlib::SubstructureRedirectMask | x11::xlib::SubstructureNotifyMask,
152
+ &mut event as *mut x11::xlib::XClientMessageEvent as *mut x11::xlib::XEvent,
153
+ );
154
+ x11::xlib::XFlush(DISPLAY);
155
+ IS_FULLSCREEN = fullscreen;
156
+ }
157
+ }
158
+
159
+ pub fn toggle_fullscreen() {
160
+ unsafe { set_fullscreen(!IS_FULLSCREEN); }
161
+ }
162
+
163
+ /// Returns (physical_w, physical_h). Caller's `width`/`height`
164
+ /// are *logical*; on a HiDPI X11 display we multiply by the
165
+ /// monitor's scale factor so the window appears the right size
166
+ /// while its surface is at the screen's physical resolution.
167
+ ///
168
+ /// `headless = true` keeps the X11 window + Vulkan surface alive
169
+ /// (wgpu needs a surface) but never maps the window — so it never
170
+ /// appears on screen and never steals focus. Mirrors the macOS
171
+ /// BLOOM_HEADLESS path for batch / CI rendering harnesses.
172
+ pub fn create_window(width: f64, height: f64, title: &str, headless: bool) -> (u32, u32) {
173
+ unsafe {
174
+ DISPLAY = x11::xlib::XOpenDisplay(std::ptr::null());
175
+ if DISPLAY.is_null() {
176
+ panic!("Failed to open X11 display");
177
+ }
178
+
179
+ let screen = x11::xlib::XDefaultScreen(DISPLAY);
180
+ let root = x11::xlib::XRootWindow(DISPLAY, screen);
181
+
182
+ let scale = display_scale(DISPLAY, screen);
183
+ let phys_w = (width * scale).round() as u32;
184
+ let phys_h = (height * scale).round() as u32;
185
+
186
+ // Off-screen origin is belt-and-braces: even if some WM
187
+ // chooses to map the window despite us never calling
188
+ // XMapWindow, it'll appear far off the visible desktop.
189
+ let origin_x: i32 = if headless { -20000 } else { 0 };
190
+ X11_WINDOW = x11::xlib::XCreateSimpleWindow(
191
+ DISPLAY, root,
192
+ origin_x, 0, phys_w, phys_h, 0,
193
+ x11::xlib::XBlackPixel(DISPLAY, screen),
194
+ x11::xlib::XWhitePixel(DISPLAY, screen),
195
+ );
196
+
197
+ let title_cstr = std::ffi::CString::new(title).unwrap();
198
+ x11::xlib::XStoreName(DISPLAY, X11_WINDOW, title_cstr.as_ptr());
199
+
200
+ x11::xlib::XSelectInput(DISPLAY, X11_WINDOW,
201
+ x11::xlib::ExposureMask | x11::xlib::KeyPressMask | x11::xlib::KeyReleaseMask |
202
+ x11::xlib::ButtonPressMask | x11::xlib::ButtonReleaseMask |
203
+ x11::xlib::PointerMotionMask | x11::xlib::StructureNotifyMask);
204
+
205
+ // Opt into the WM close protocol before mapping, so the titlebar
206
+ // ✕ / Alt-F4 arrives as a ClientMessage we can turn into
207
+ // `windowShouldClose()` instead of the WM dropping our X
208
+ // connection and Xlib exit(1)-ing the game mid-frame.
209
+ WM_PROTOCOLS = x11::xlib::XInternAtom(
210
+ DISPLAY, b"WM_PROTOCOLS\0".as_ptr() as *const _, 0);
211
+ WM_DELETE_WINDOW = x11::xlib::XInternAtom(
212
+ DISPLAY, b"WM_DELETE_WINDOW\0".as_ptr() as *const _, 0);
213
+ if WM_DELETE_WINDOW != 0 {
214
+ let mut protocols = [WM_DELETE_WINDOW];
215
+ x11::xlib::XSetWMProtocols(
216
+ DISPLAY, X11_WINDOW, protocols.as_mut_ptr(), 1);
217
+ }
218
+
219
+ if !headless {
220
+ x11::xlib::XMapWindow(DISPLAY, X11_WINDOW);
221
+ }
222
+ x11::xlib::XFlush(DISPLAY);
223
+ (phys_w, phys_h)
224
+ }
225
+ }
226
+
227
+ pub fn set_no_fullscreen(no_fs: bool) { unsafe { NO_FULLSCREEN = no_fs; } }
228
+
229
+ /// Read the current display's DPI scale factor. Computed from
230
+ /// physical screen dimensions (pixels / mm). Snapped to the
231
+ /// nearest 0.25 and clamped to [1.0, 4.0] — EDID often lies
232
+ /// about millimetres so we want stable integer-ish steps. A
233
+ /// real Wayland-style desktop environment with explicit scale
234
+ /// settings is out of scope here; this matches what most X11
235
+ /// users actually run (fractional scaling via Xft.dpi is the
236
+ /// only thing GTK/Qt honour by default).
237
+ pub fn display_scale(display: *mut x11::xlib::Display, screen: i32) -> f64 {
238
+ unsafe {
239
+ let pixels = x11::xlib::XDisplayWidth(display, screen) as f64;
240
+ let mm = x11::xlib::XDisplayWidthMM(display, screen) as f64;
241
+ if mm <= 0.0 || pixels <= 0.0 { return 1.0; }
242
+ let dpi = pixels / (mm / 25.4);
243
+ // 96 DPI = scale 1.0. Snap to 0.25 steps.
244
+ let raw = (dpi / 96.0).max(1.0).min(4.0);
245
+ (raw * 4.0).round() / 4.0
246
+ }
247
+ }
248
+
249
+ pub fn display() -> *mut x11::xlib::Display { unsafe { DISPLAY } }
250
+ pub fn window() -> x11::xlib::Window { unsafe { X11_WINDOW } }
251
+
252
+ pub fn set_window_title(title: &str) {
253
+ unsafe {
254
+ if DISPLAY.is_null() || X11_WINDOW == 0 { return; }
255
+ let cstr = match std::ffi::CString::new(title) { Ok(c) => c, Err(_) => return };
256
+ x11::xlib::XStoreName(DISPLAY, X11_WINDOW, cstr.as_ptr());
257
+ // Modern WMs honour _NET_WM_NAME (UTF-8) over the legacy
258
+ // WM_NAME (Latin-1) set by XStoreName, so write both.
259
+ let net_wm_name = x11::xlib::XInternAtom(
260
+ DISPLAY, b"_NET_WM_NAME\0".as_ptr() as *const _, 0);
261
+ let utf8_string = x11::xlib::XInternAtom(
262
+ DISPLAY, b"UTF8_STRING\0".as_ptr() as *const _, 0);
263
+ if net_wm_name != 0 && utf8_string != 0 {
264
+ x11::xlib::XChangeProperty(
265
+ DISPLAY, X11_WINDOW, net_wm_name, utf8_string, 8,
266
+ x11::xlib::PropModeReplace,
267
+ title.as_ptr(), title.len() as i32,
268
+ );
269
+ }
270
+ x11::xlib::XFlush(DISPLAY);
271
+ }
272
+ }
273
+
274
+ /// Set the EWMH `_NET_WM_ICON` property from an image file. The X11
275
+ /// icon format is a flat array of CARDINALs (`long`s on the wire):
276
+ /// `[width, height, pixel0, pixel1, ...]` with each pixel as ARGB
277
+ /// premultiplied-alpha packed into the low 32 bits of a long.
278
+ pub fn set_window_icon(path: &str) {
279
+ unsafe {
280
+ if DISPLAY.is_null() || X11_WINDOW == 0 { return; }
281
+ let img = match image::open(path) {
282
+ Ok(i) => i.to_rgba8(),
283
+ Err(_) => return,
284
+ };
285
+ let (w, h) = (img.width() as usize, img.height() as usize);
286
+ let mut buf: Vec<std::os::raw::c_long> = Vec::with_capacity(2 + w * h);
287
+ buf.push(w as std::os::raw::c_long);
288
+ buf.push(h as std::os::raw::c_long);
289
+ for px in img.chunks_exact(4) {
290
+ let r = px[0] as u32;
291
+ let g = px[1] as u32;
292
+ let b = px[2] as u32;
293
+ let a = px[3] as u32;
294
+ let argb = (a << 24) | (r << 16) | (g << 8) | b;
295
+ buf.push(argb as std::os::raw::c_long);
296
+ }
297
+ let net_wm_icon = x11::xlib::XInternAtom(
298
+ DISPLAY, b"_NET_WM_ICON\0".as_ptr() as *const _, 0);
299
+ let cardinal = x11::xlib::XInternAtom(
300
+ DISPLAY, b"CARDINAL\0".as_ptr() as *const _, 0);
301
+ x11::xlib::XChangeProperty(
302
+ DISPLAY, X11_WINDOW, net_wm_icon, cardinal, 32,
303
+ x11::xlib::PropModeReplace,
304
+ buf.as_ptr() as *const u8,
305
+ buf.len() as i32,
306
+ );
307
+ x11::xlib::XFlush(DISPLAY);
308
+ }
309
+ }
310
+
311
+ /// Build (once) a 1x1 fully-transparent cursor — the standard X11
312
+ /// trick for "hide the cursor". Subsequent calls reuse the cached
313
+ /// cursor since X11 leaks Cursor handles otherwise.
314
+ unsafe fn ensure_hidden_cursor() -> x11::xlib::Cursor {
315
+ if HIDDEN_CURSOR != 0 { return HIDDEN_CURSOR; }
316
+ let pixmap = x11::xlib::XCreatePixmap(DISPLAY, X11_WINDOW, 1, 1, 1);
317
+ let mut color: x11::xlib::XColor = std::mem::zeroed();
318
+ let cursor = x11::xlib::XCreatePixmapCursor(
319
+ DISPLAY, pixmap, pixmap, &mut color, &mut color, 0, 0);
320
+ x11::xlib::XFreePixmap(DISPLAY, pixmap);
321
+ HIDDEN_CURSOR = cursor;
322
+ cursor
323
+ }
324
+
325
+ pub fn hide_cursor() {
326
+ unsafe {
327
+ if DISPLAY.is_null() || X11_WINDOW == 0 || CURSOR_HIDDEN { return; }
328
+ let c = ensure_hidden_cursor();
329
+ x11::xlib::XDefineCursor(DISPLAY, X11_WINDOW, c);
330
+ x11::xlib::XFlush(DISPLAY);
331
+ CURSOR_HIDDEN = true;
332
+ }
333
+ }
334
+
335
+ pub fn show_cursor() {
336
+ unsafe {
337
+ if DISPLAY.is_null() || X11_WINDOW == 0 || !CURSOR_HIDDEN { return; }
338
+ x11::xlib::XUndefineCursor(DISPLAY, X11_WINDOW);
339
+ x11::xlib::XFlush(DISPLAY);
340
+ CURSOR_HIDDEN = false;
341
+ }
342
+ }
343
+
344
+ /// Warp the pointer to window center and remember where we put it so
345
+ /// the motion handler can compute deltas relative to the warp target.
346
+ pub fn warp_to_center() {
347
+ unsafe {
348
+ if DISPLAY.is_null() || X11_WINDOW == 0 { return; }
349
+ let mut attrs: x11::xlib::XWindowAttributes = std::mem::zeroed();
350
+ x11::xlib::XGetWindowAttributes(DISPLAY, X11_WINDOW, &mut attrs);
351
+ let cx = attrs.width / 2;
352
+ let cy = attrs.height / 2;
353
+ x11::xlib::XWarpPointer(DISPLAY, 0, X11_WINDOW, 0, 0, 0, 0, cx, cy);
354
+ x11::xlib::XFlush(DISPLAY);
355
+ WARP_CENTER_X = cx;
356
+ WARP_CENTER_Y = cy;
357
+ }
358
+ }
359
+
360
+ pub fn enter_relative_mode() {
361
+ unsafe {
362
+ RELATIVE_MODE = true;
363
+ hide_cursor();
364
+ warp_to_center();
365
+ }
366
+ }
367
+
368
+ pub fn leave_relative_mode() {
369
+ unsafe {
370
+ RELATIVE_MODE = false;
371
+ show_cursor();
372
+ }
373
+ }
374
+
375
+ /// Apply the requested cursor shape (the same 0..=6 enum macOS uses
376
+ /// in NSCursor calls). XCreateFontCursor uses cursor-font glyph
377
+ /// constants from <X11/cursorfont.h>; we cache one Cursor per shape
378
+ /// so repeat calls don't leak server-side state.
379
+ pub fn apply_cursor_shape(shape: u32) {
380
+ unsafe {
381
+ if DISPLAY.is_null() || X11_WINDOW == 0 || CURSOR_HIDDEN { return; }
382
+ let mut cache = CURSOR_CACHE
383
+ .lock()
384
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
385
+ if shape == cache.last_applied_shape { return; }
386
+ // X11 cursor-font glyph indices (from cursorfont.h).
387
+ // 0 = default arrow → XC_left_ptr (68)
388
+ // 1 = pointing hand → XC_hand2 (60)
389
+ // 2 = open hand → XC_fleur (52, "move")
390
+ // 3 = I-beam → XC_xterm (152)
391
+ // 4 = resize H → XC_sb_h_double_arrow (108)
392
+ // 5 = resize V → XC_sb_v_double_arrow (116)
393
+ // 6 = crosshair → XC_crosshair (34)
394
+ let glyph: u32 = match shape {
395
+ 1 => 60,
396
+ 2 => 52,
397
+ 3 => 152,
398
+ 4 => 108,
399
+ 5 => 116,
400
+ 6 => 34,
401
+ _ => 68,
402
+ };
403
+ let idx = (shape as usize).min(cache.cursors.len() - 1);
404
+ if cache.cursors[idx] == 0 {
405
+ cache.cursors[idx] = x11::xlib::XCreateFontCursor(DISPLAY, glyph);
406
+ }
407
+ if cache.cursors[idx] != 0 {
408
+ x11::xlib::XDefineCursor(DISPLAY, X11_WINDOW, cache.cursors[idx]);
409
+ x11::xlib::XFlush(DISPLAY);
410
+ cache.last_applied_shape = shape;
411
+ }
412
+ }
413
+ }
414
+
415
+ pub fn poll_events() {
416
+ unsafe {
417
+ while x11::xlib::XPending(DISPLAY) > 0 {
418
+ let mut event = std::mem::zeroed::<x11::xlib::XEvent>();
419
+ x11::xlib::XNextEvent(DISPLAY, &mut event);
420
+
421
+ match event.type_ {
422
+ x11::xlib::KeyPress => {
423
+ let keysym = x11::xlib::XLookupKeysym(
424
+ &mut event.key as *mut _ as *mut _,
425
+ 0,
426
+ );
427
+ let bloom_key = map_keycode(keysym as u32);
428
+ if bloom_key > 0 {
429
+ engine().input.set_key_down(bloom_key);
430
+ }
431
+ // Decode UTF-8 typed text via XLookupString so the
432
+ // editor's text-input widget receives characters.
433
+ let mut buf = [0u8; 32];
434
+ let mut ks: x11::xlib::KeySym = 0;
435
+ let len = x11::xlib::XLookupString(
436
+ &mut event.key as *mut _,
437
+ buf.as_mut_ptr() as *mut i8,
438
+ buf.len() as i32,
439
+ &mut ks,
440
+ std::ptr::null_mut(),
441
+ );
442
+ if len > 0 {
443
+ if let Ok(s) = std::str::from_utf8(&buf[..len as usize]) {
444
+ for c in s.chars() {
445
+ let cp = c as u32;
446
+ if cp >= 32 || cp == 8 || cp == 13 || cp == 9 {
447
+ engine().input.push_char(cp);
448
+ }
449
+ }
450
+ }
451
+ }
452
+ }
453
+ x11::xlib::KeyRelease => {
454
+ let keysym = x11::xlib::XLookupKeysym(
455
+ &mut event.key as *mut _ as *mut _,
456
+ 0,
457
+ );
458
+ let bloom_key = map_keycode(keysym as u32);
459
+ if bloom_key > 0 {
460
+ engine().input.set_key_up(bloom_key);
461
+ }
462
+ }
463
+ x11::xlib::MotionNotify => {
464
+ let motion = event.motion;
465
+ if RELATIVE_MODE {
466
+ // Ignore the motion event we generated by warping
467
+ // back to center — it would otherwise add a stray
468
+ // -delta cancelling the user's actual movement.
469
+ if motion.x == WARP_CENTER_X && motion.y == WARP_CENTER_Y {
470
+ // synthetic warp event; skip
471
+ } else {
472
+ let dx = (motion.x - WARP_CENTER_X) as f64;
473
+ let dy = (motion.y - WARP_CENTER_Y) as f64;
474
+ engine().input.accumulate_mouse_delta(dx, dy);
475
+ warp_to_center();
476
+ }
477
+ } else {
478
+ engine().input.set_mouse_position(motion.x as f64, motion.y as f64);
479
+ }
480
+ }
481
+ x11::xlib::ButtonPress => {
482
+ let button = event.button.button;
483
+ match button {
484
+ 1 => engine().input.set_mouse_button_down(0),
485
+ 3 => engine().input.set_mouse_button_down(1),
486
+ 2 => engine().input.set_mouse_button_down(2),
487
+ // X11 maps wheel up/down to button 4/5 and
488
+ // horizontal scroll to 6/7. macOS uses an
489
+ // accumulator with positive = scroll up; flip
490
+ // the sign on the down case to match.
491
+ 4 => engine().input.accumulate_mouse_wheel(1.0),
492
+ 5 => engine().input.accumulate_mouse_wheel(-1.0),
493
+ _ => {}
494
+ }
495
+ }
496
+ x11::xlib::ButtonRelease => {
497
+ let button = event.button.button;
498
+ match button {
499
+ 1 => engine().input.set_mouse_button_up(0),
500
+ 3 => engine().input.set_mouse_button_up(1),
501
+ 2 => engine().input.set_mouse_button_up(2),
502
+ _ => {}
503
+ }
504
+ }
505
+ x11::xlib::ConfigureNotify => {
506
+ let configure = event.configure;
507
+ let phys_w = configure.width as u32;
508
+ let phys_h = configure.height as u32;
509
+ if phys_w > 0 && phys_h > 0 {
510
+ let mut eng = engine();
511
+ if phys_w != eng.renderer.physical_width()
512
+ || phys_h != eng.renderer.physical_height()
513
+ {
514
+ let screen = x11::xlib::XDefaultScreen(DISPLAY);
515
+ let scale = display_scale(DISPLAY, screen);
516
+ let log_w = ((phys_w as f64) / scale).round() as u32;
517
+ let log_h = ((phys_h as f64) / scale).round() as u32;
518
+ eng.renderer.resize(phys_w, phys_h, log_w, log_h);
519
+ }
520
+ }
521
+ }
522
+ x11::xlib::ClientMessage => {
523
+ // The WM asking us to close (titlebar ✕, Alt-F4, or a
524
+ // session logout). Without WM_DELETE_WINDOW in our
525
+ // WM_PROTOCOLS the WM instead destroys the connection,
526
+ // and Xlib's default IO-error handler calls exit(1)
527
+ // from under the game — `windowShouldClose()` never
528
+ // goes true, so `closeWindow()` and every shutdown
529
+ // path after it (audio teardown, save-on-exit) are
530
+ // skipped. Observed as:
531
+ // XIO: fatal IO error 62 (Timer expired)
532
+ if event.client_message.message_type == wm_protocols_atom()
533
+ && event.client_message.data.get_long(0)
534
+ == wm_delete_window_atom() as i64
535
+ {
536
+ engine().should_close = true;
537
+ }
538
+ }
539
+ x11::xlib::DestroyNotify => {
540
+ engine().should_close = true;
541
+ }
542
+ _ => {}
543
+ }
544
+ }
545
+ }
546
+ }
547
+ }
548
+
549
+ #[no_mangle]
550
+ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u8, fullscreen: f64) {
551
+ let title = str_from_header(title_ptr);
552
+
553
+ #[cfg(target_os = "linux")]
554
+ {
555
+ // Headless mode: BLOOM_HEADLESS=1 keeps the X11 window + Vulkan
556
+ // surface alive (wgpu requires a real surface) but never maps
557
+ // the window so it's invisible and never steals focus. Lets an
558
+ // agent spin up the renderer in a batch loop without disturbing
559
+ // the user's desktop.
560
+ let headless = std::env::var("BLOOM_HEADLESS")
561
+ .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
562
+ .unwrap_or(false);
563
+ // BLOOM_NO_FULLSCREEN=1 hard-disables fullscreen capability for
564
+ // benchmark harnesses where a 4K-display fullscreen path would
565
+ // silently quadruple render cost.
566
+ let no_fullscreen = std::env::var("BLOOM_NO_FULLSCREEN")
567
+ .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
568
+ .unwrap_or(false);
569
+ x11_impl::set_no_fullscreen(no_fullscreen);
570
+
571
+ let (phys_w, phys_h) = x11_impl::create_window(width, height, title, headless);
572
+
573
+ let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
574
+ backends: wgpu::Backends::VULKAN | wgpu::Backends::GL,
575
+ ..wgpu::InstanceDescriptor::new_without_display_handle()
576
+ });
577
+
578
+ let surface = unsafe {
579
+ let raw_window = raw_window_handle::RawWindowHandle::Xlib(
580
+ raw_window_handle::XlibWindowHandle::new(x11_impl::window())
581
+ );
582
+ let raw_display = raw_window_handle::RawDisplayHandle::Xlib(
583
+ raw_window_handle::XlibDisplayHandle::new(
584
+ std::ptr::NonNull::new(x11_impl::display() as *mut _),
585
+ 0,
586
+ )
587
+ );
588
+ instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle {
589
+ raw_display_handle: Some(raw_display),
590
+ raw_window_handle: raw_window,
591
+ }).expect("Failed to create surface")
592
+ };
593
+
594
+ let adapter = pollster_block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
595
+ compatible_surface: Some(&surface),
596
+ ..Default::default()
597
+ })).expect("No adapter found");
598
+
599
+ // Ticket 007b: HW ray-query via VK_KHR_ray_query on RT-capable
600
+ // desktop Linux GPUs. Older integrated GPUs will fall back to
601
+ // the SW path through this gate.
602
+ let supported = adapter.features();
603
+ let force_sw_gi = std::env::var("BLOOM_FORCE_SW_GI")
604
+ .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
605
+ .unwrap_or(false);
606
+ let rt_mask = wgpu::Features::EXPERIMENTAL_RAY_QUERY;
607
+ let mut required_features = wgpu::Features::empty();
608
+ // Ticket 011: request TIMESTAMP_QUERY when supported so the profiler
609
+ // can record GPU timings. Optional — profiler falls back to CPU-only
610
+ // when the adapter doesn't grant it.
611
+ if supported.contains(wgpu::Features::TIMESTAMP_QUERY) {
612
+ required_features |= wgpu::Features::TIMESTAMP_QUERY;
613
+ }
614
+ // Cooked BC7 textures (bloom-cook) upload compressed when the
615
+ // adapter has BC support; without it they CPU-decode at load.
616
+ if supported.contains(wgpu::Features::TEXTURE_COMPRESSION_BC) {
617
+ required_features |= wgpu::Features::TEXTURE_COMPRESSION_BC;
618
+ }
619
+ if !force_sw_gi && supported.contains(rt_mask) {
620
+ required_features |= rt_mask;
621
+ }
622
+ // PT-2: texture binding array + non-uniform indexing for textured
623
+ // path-trace hit shading. Both or neither (the kernel indexes the
624
+ // array with a per-thread material id).
625
+ let pt_tex_mask = wgpu::Features::TEXTURE_BINDING_ARRAY
626
+ | wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING;
627
+ if supported.contains(pt_tex_mask) {
628
+ required_features |= pt_tex_mask;
629
+ }
630
+ let experimental_features = if required_features.intersects(rt_mask) {
631
+ unsafe { wgpu::ExperimentalFeatures::enabled() }
632
+ } else {
633
+ wgpu::ExperimentalFeatures::disabled()
634
+ };
635
+ let mut required_limits = wgpu::Limits::default();
636
+ // The material ABI declares 5 bind groups (PerFrame, PerView,
637
+ // PerMaterial, PerDraw, SceneInputs). wgpu's default limit is
638
+ // 4. Vulkan supports at least 7 here, so 5 is universally safe.
639
+ required_limits.max_bind_groups = 5;
640
+ // The refractive/translucent material profile binds up to 19
641
+ // sampled textures in the fragment stage (5 material maps + env/
642
+ // BRDF/3 shadow cascades/env-diffuse + planar reflection + 3 texture
643
+ // arrays + the group-4 scene_color/scene_depth/impulse/motion inputs).
644
+ // wgpu's default is 16. Raise to whatever the adapter actually
645
+ // supports — every real Vulkan/GL GPU exposes ≥128 — so opaque/
646
+ // transparent materials are unaffected and refractive ones link.
647
+ let adapter_limits = adapter.limits();
648
+ required_limits.max_sampled_textures_per_shader_stage = required_limits
649
+ .max_sampled_textures_per_shader_stage
650
+ .max(adapter_limits.max_sampled_textures_per_shader_stage);
651
+ required_limits.max_samplers_per_shader_stage = required_limits
652
+ .max_samplers_per_shader_stage
653
+ .max(adapter_limits.max_samplers_per_shader_stage);
654
+ // PT-2: binding arrays have their own element budget, default 0.
655
+ // Take whatever the adapter offers; the renderer checks the
656
+ // granted value against its fixed array size before compiling
657
+ // the textured kernel variant.
658
+ if required_features.contains(pt_tex_mask) {
659
+ required_limits.max_binding_array_elements_per_shader_stage =
660
+ adapter_limits.max_binding_array_elements_per_shader_stage;
661
+ }
662
+ // PT-4: the path-trace kernel binds 9 storage buffers (accum +
663
+ // moments + reservoir ping-pongs on top of instance/geo data);
664
+ // the wgpu default limit is 8.
665
+ required_limits.max_storage_buffers_per_shader_stage = required_limits
666
+ .max_storage_buffers_per_shader_stage
667
+ .max(adapter_limits.max_storage_buffers_per_shader_stage.min(16));
668
+ if required_features.intersects(rt_mask) {
669
+ required_limits = required_limits
670
+ .using_minimum_supported_acceleration_structure_values();
671
+ }
672
+ let (device, queue) = pollster_block_on(adapter.request_device(
673
+ &wgpu::DeviceDescriptor {
674
+ label: Some("bloom_device"),
675
+ required_features,
676
+ required_limits,
677
+ experimental_features,
678
+ ..Default::default()
679
+ },
680
+ )).expect("Failed to create device");
681
+
682
+ let surface_caps = surface.get_capabilities(&adapter);
683
+ let format = surface_caps.formats[0];
684
+ // Surface sized at the *physical* pixel count we computed
685
+ // from XDisplayWidth/MM; renderer is told the caller's
686
+ // logical size separately so screenWidth() etc. stay
687
+ // DPI-independent.
688
+ let surface_config = wgpu::SurfaceConfiguration {
689
+ // COPY_SRC: bloom_take_screenshot reads the swapchain back;
690
+ // without it the readback copy is a validation error that
691
+ // aborts the process the first time a game calls it.
692
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT
693
+ | wgpu::TextureUsages::COPY_SRC,
694
+ format,
695
+ width: phys_w,
696
+ height: phys_h,
697
+ present_mode: wgpu::PresentMode::Fifo,
698
+ alpha_mode: surface_caps.alpha_modes[0],
699
+ view_formats: vec![],
700
+ desired_maximum_frame_latency: 2,
701
+ };
702
+ surface.configure(&device, &surface_config);
703
+
704
+ let renderer = Renderer::new(device, queue, surface, surface_config, width as u32, height as u32);
705
+ let _ = ENGINE.set(Mutex::new(EngineState::new(renderer)));
706
+
707
+ if fullscreen != 0.0 {
708
+ x11_impl::set_fullscreen(true);
709
+ }
710
+
711
+ // Register Bloom's GPU screenshot capture with perry-geisterhand.
712
+ // perry-runtime always exposes these symbols, so the link is direct
713
+ // (not weak); the registry no-ops gracefully if the editor isn't
714
+ // running.
715
+ bloom_register_geisterhand_screenshot();
716
+ }
717
+
718
+ #[cfg(not(target_os = "linux"))]
719
+ panic!("bloom-linux can only run on Linux");
720
+ }
721
+
722
+ /// Attach the engine to a host-owned surface (PerryTS/perry#5519).
723
+ ///
724
+ /// Not yet wired on Linux: Perry UI's GTK4 `BloomView` hands out a
725
+ /// `GtkWidget*`, and turning that into a wgpu surface needs the widget
726
+ /// realized/mapped and its `GdkSurface` bridged to an X11 `Window` (or a
727
+ /// Wayland `wl_surface`) — the GTK4 dmabuf/`GtkGLArea` path the issue
728
+ /// calls out as the larger follow-up. Until that lands this returns 0.0
729
+ /// (failure) so hosts fall back to the windowed `bloom_init_window`
730
+ /// path. The symbol exists so the FFI surface is uniform across
731
+ /// platforms (the shared bring-up is `bloom_shared::attach::attach_engine`,
732
+ /// already used by the Apple/Android/Windows attach paths).
733
+ #[no_mangle]
734
+ pub extern "C" fn bloom_attach_native(handle: i64, width: f64, height: f64) -> f64 {
735
+ let _ = (handle, width, height);
736
+ 0.0
737
+ }
738
+
739
+ /// Resize the engine's surface (#70 parity; used by host-driven
740
+ /// BloomViews on layout changes). `phys_*` physical px, `log_*` logical.
741
+ #[no_mangle]
742
+ pub extern "C" fn bloom_resize(phys_w: f64, phys_h: f64, log_w: f64, log_h: f64) {
743
+ if let Some(lock) = ENGINE.get() {
744
+ let mut eng = lock
745
+ .lock()
746
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
747
+ eng.renderer.resize(phys_w as u32, phys_h as u32, log_w as u32, log_h as u32);
748
+ }
749
+ }
750
+
751
+ /// HWND host-embed (#70) — Windows only; a no-op here for FFI-manifest
752
+ /// parity. Non-Windows hosts attach via `bloom_attach_native`.
753
+ #[no_mangle]
754
+ pub extern "C" fn bloom_attach_hwnd(_hwnd_bits: f64, _width: f64, _height: f64) {}
755
+
756
+ #[no_mangle]
757
+ pub extern "C" fn bloom_close_window() {
758
+ // Request a clean exit — the game loop's next windowShouldClose() check
759
+ // reports true (mirrors the Windows implementation; was an empty stub).
760
+ engine().should_close = true;
761
+ }
762
+
763
+ #[no_mangle]
764
+ pub extern "C" fn bloom_window_should_close() -> f64 {
765
+ if engine().should_close { 1.0 } else { 0.0 }
766
+ }
767
+
768
+ #[cfg(target_os = "linux")]
769
+ fn poll_linux_gamepad() {
770
+ unsafe {
771
+ // Try to open gamepad if not already
772
+ if GAMEPAD_FD < 0 {
773
+ let path = b"/dev/input/js0\0";
774
+ // Open non-blocking
775
+ GAMEPAD_FD = libc::open(path.as_ptr() as *const libc::c_char, libc::O_RDONLY | libc::O_NONBLOCK);
776
+ if GAMEPAD_FD >= 0 {
777
+ engine().input.gamepad_available = true;
778
+ engine().input.gamepad_axis_count = 6;
779
+ }
780
+ }
781
+ if GAMEPAD_FD < 0 { return; }
782
+
783
+ // Linux joystick event structure
784
+ #[repr(C)]
785
+ struct JsEvent {
786
+ time: u32,
787
+ value: i16,
788
+ event_type: u8,
789
+ number: u8,
790
+ }
791
+
792
+ loop {
793
+ let mut event = std::mem::zeroed::<JsEvent>();
794
+ let n = libc::read(
795
+ GAMEPAD_FD,
796
+ &mut event as *mut _ as *mut libc::c_void,
797
+ std::mem::size_of::<JsEvent>(),
798
+ );
799
+ if n != std::mem::size_of::<JsEvent>() as isize { break; }
800
+
801
+ let type_masked = event.event_type & 0x7f; // strip JS_EVENT_INIT
802
+ if type_masked == 1 {
803
+ // Button
804
+ let idx = event.number as usize;
805
+ if event.value != 0 {
806
+ engine().input.set_gamepad_button_down(idx);
807
+ } else {
808
+ engine().input.set_gamepad_button_up(idx);
809
+ }
810
+ } else if type_masked == 2 {
811
+ // Axis
812
+ let idx = event.number as usize;
813
+ let value = event.value as f32 / 32767.0;
814
+ engine().input.set_gamepad_axis(idx, value);
815
+ }
816
+ }
817
+ }
818
+ }
819
+
820
+ #[no_mangle]
821
+ pub extern "C" fn bloom_begin_drawing() {
822
+ #[cfg(target_os = "linux")]
823
+ {
824
+ x11_impl::poll_events();
825
+ poll_linux_gamepad();
826
+ // Apply Q2 cursor shape — mirrors macOS NSCursor calls in its
827
+ // event loop. set_cursor_shape just stores the value; the X11
828
+ // cursor only changes when we actually call XDefineCursor.
829
+ x11_impl::apply_cursor_shape(engine().input.cursor_shape);
830
+ }
831
+ let (delta_time, callbacks) = {
832
+ let mut eng = engine();
833
+ eng.begin_frame_without_callbacks();
834
+ eng.begin_frame_callbacks()
835
+ };
836
+ for callback in callbacks {
837
+ callback(delta_time);
838
+ }
839
+ engine().finish_frame_callbacks();
840
+ }
841
+
842
+ #[no_mangle]
843
+ pub extern "C" fn bloom_end_drawing() {
844
+ // Pump geisterhand BEFORE end_frame. Mirrors macOS — the screenshot
845
+ // function re-renders inline using the captured VP + vertex buffers.
846
+ extern "C" { fn perry_geisterhand_pump(); }
847
+ unsafe { perry_geisterhand_pump(); }
848
+
849
+ engine().end_frame();
850
+ }
851
+
852
+ static AUDIO_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
853
+
854
+ #[no_mangle]
855
+ pub extern "C" fn bloom_init_audio() {
856
+ #[cfg(target_os = "linux")]
857
+ {
858
+ use std::sync::atomic::Ordering;
859
+ AUDIO_RUNNING.store(true, Ordering::SeqCst);
860
+ // Move the render half into the audio thread; the engine keeps
861
+ // only the command-producing control half.
862
+ let renderer = engine().audio.take_renderer();
863
+ std::thread::spawn(move || {
864
+ alsa_audio_thread(renderer);
865
+ });
866
+ }
867
+ }
868
+
869
+ #[no_mangle]
870
+ pub extern "C" fn bloom_close_audio() {
871
+ AUDIO_RUNNING.store(false, std::sync::atomic::Ordering::SeqCst);
872
+ std::thread::sleep(std::time::Duration::from_millis(50));
873
+ }
874
+
875
+ #[cfg(target_os = "linux")]
876
+ fn alsa_audio_thread(mut renderer: Option<bloom_shared::audio::AudioRenderer>) {
877
+ use alsa::pcm::*;
878
+ use alsa::{Direction, ValueOr};
879
+ use std::sync::atomic::Ordering;
880
+
881
+ let pcm = match PCM::new("default", Direction::Playback, false) {
882
+ Ok(p) => p,
883
+ Err(_) => return,
884
+ };
885
+
886
+ let sample_rate = 44100u32;
887
+ let channels = 2u32;
888
+ let period_size = 1024;
889
+
890
+ {
891
+ let hwp = HwParams::any(&pcm).unwrap();
892
+ let _ = hwp.set_channels(channels);
893
+ let _ = hwp.set_rate(sample_rate, ValueOr::Nearest);
894
+ let _ = hwp.set_format(Format::float());
895
+ let _ = hwp.set_access(Access::RWInterleaved);
896
+ let _ = hwp.set_period_size(period_size, ValueOr::Nearest);
897
+ let _ = hwp.set_buffer_size(period_size * 4);
898
+ let _ = pcm.hw_params(&hwp);
899
+ }
900
+
901
+ let _ = pcm.prepare();
902
+
903
+ let frames = period_size as usize;
904
+ let mut mix_buf = vec![0.0f32; frames * channels as usize];
905
+
906
+ while AUDIO_RUNNING.load(Ordering::SeqCst) {
907
+ for s in mix_buf.iter_mut() { *s = 0.0; }
908
+
909
+ // Renderer is moved into this thread at spawn — no shared
910
+ // engine state is touched from here (see audio/mod.rs contract).
911
+ if let Some(r) = renderer.as_mut() {
912
+ r.mix(&mut mix_buf);
913
+ }
914
+
915
+ let io = pcm.io_f32().unwrap();
916
+ match io.writei(&mix_buf) {
917
+ Ok(_) => {}
918
+ Err(e) => {
919
+ let _ = pcm.try_recover(e, true);
920
+ }
921
+ }
922
+ }
923
+
924
+ let _ = pcm.drain();
925
+ }
926
+
927
+ // --- Texture FFI ---
928
+
929
+ // --- Camera FFI ---
930
+
931
+ // --- 3D Drawing FFI ---
932
+
933
+ // --- Model FFI ---
934
+
935
+ // ============================================================
936
+ // Phase 1c — material system FFI
937
+ // ============================================================
938
+
939
+ // --- Music FFI ---
940
+
941
+ // --- Gamepad FFI ---
942
+
943
+ // --- Skeletal Animation Debug ---
944
+
945
+ // --- Lighting ---
946
+
947
+ // --- Utility FFI ---
948
+
949
+ #[no_mangle]
950
+ pub extern "C" fn bloom_toggle_fullscreen() {
951
+ #[cfg(target_os = "linux")]
952
+ x11_impl::toggle_fullscreen();
953
+ }
954
+ #[no_mangle]
955
+ pub extern "C" fn bloom_set_window_title(title_ptr: *const u8) {
956
+ let title = str_from_header(title_ptr);
957
+ #[cfg(target_os = "linux")]
958
+ x11_impl::set_window_title(title);
959
+ }
960
+ #[no_mangle]
961
+ pub extern "C" fn bloom_set_window_icon(path_ptr: *const u8) {
962
+ let path = str_from_header(path_ptr);
963
+ #[cfg(target_os = "linux")]
964
+ x11_impl::set_window_icon(path);
965
+ }
966
+
967
+ #[no_mangle]
968
+ pub extern "C" fn bloom_disable_cursor() {
969
+ let input = &mut engine().input;
970
+ input.cursor_disabled = true;
971
+ input.clear_mouse_delta();
972
+ #[cfg(target_os = "linux")]
973
+ x11_impl::enter_relative_mode();
974
+ }
975
+
976
+ #[no_mangle]
977
+ pub extern "C" fn bloom_enable_cursor() {
978
+ engine().input.cursor_disabled = false;
979
+ #[cfg(target_os = "linux")]
980
+ x11_impl::leave_relative_mode();
981
+ }
982
+
983
+ // E4: Clipboard (arboard, X11/Wayland-aware)
984
+ #[no_mangle]
985
+ pub extern "C" fn bloom_set_clipboard_text(text_ptr: *const u8) {
986
+ let text = str_from_header(text_ptr);
987
+ if let Ok(mut clipboard) = arboard::Clipboard::new() {
988
+ let _ = clipboard.set_text(text.to_string());
989
+ }
990
+ }
991
+ #[no_mangle]
992
+ pub extern "C" fn bloom_get_clipboard_text() -> *const u8 {
993
+ match arboard::Clipboard::new() {
994
+ Ok(mut clipboard) => match clipboard.get_text() {
995
+ Ok(text) => alloc_perry_string(&text),
996
+ Err(_) => alloc_perry_string(""),
997
+ },
998
+ Err(_) => alloc_perry_string(""),
999
+ }
1000
+ }
1001
+
1002
+ // E5b: Native file dialogs (rfd → GTK/zenity/kdialog on Linux)
1003
+ #[no_mangle]
1004
+ pub extern "C" fn bloom_open_file_dialog(filter_ptr: *const u8, title_ptr: *const u8) -> *const u8 {
1005
+ let filter = str_from_header(filter_ptr);
1006
+ let title = str_from_header(title_ptr);
1007
+ let mut dialog = rfd::FileDialog::new().set_title(title);
1008
+ if !filter.is_empty() {
1009
+ dialog = dialog.add_filter("Files", &[filter]);
1010
+ }
1011
+ match dialog.pick_file() {
1012
+ Some(path) => alloc_perry_string(&path.to_string_lossy()),
1013
+ None => alloc_perry_string(""),
1014
+ }
1015
+ }
1016
+ #[no_mangle]
1017
+ pub extern "C" fn bloom_save_file_dialog(default_name_ptr: *const u8, title_ptr: *const u8) -> *const u8 {
1018
+ let default_name = str_from_header(default_name_ptr);
1019
+ let title = str_from_header(title_ptr);
1020
+ let dialog = rfd::FileDialog::new()
1021
+ .set_title(title)
1022
+ .set_file_name(default_name);
1023
+ match dialog.save_file() {
1024
+ Some(path) => alloc_perry_string(&path.to_string_lossy()),
1025
+ None => alloc_perry_string(""),
1026
+ }
1027
+ }
1028
+ #[no_mangle]
1029
+ pub extern "C" fn bloom_get_platform() -> f64 { 4.0 }
1030
+
1031
+ /// Preferred OS language packed as `c0*256+c1` (ISO-639 primary subtag), from $LANG/$LC_*.
1032
+ #[no_mangle]
1033
+ pub extern "C" fn bloom_get_language() -> f64 {
1034
+ fn pack(code: &str) -> f64 { let l = code.to_ascii_lowercase(); let b = l.as_bytes(); if b.len() >= 2 { (b[0] as f64) * 256.0 + (b[1] as f64) } else { 25966.0 } }
1035
+ let v = std::env::var("LANG").or_else(|_| std::env::var("LC_ALL")).or_else(|_| std::env::var("LC_MESSAGES")).unwrap_or_default();
1036
+ if v.len() >= 2 && !v.starts_with('C') && !v.starts_with("POSIX") { pack(&v) } else { 25966.0 }
1037
+ }
1038
+
1039
+ // ============================================================
1040
+ // Frame callbacks
1041
+ // ============================================================
1042
+
1043
+ // ============================================================
1044
+ // Multiple lights
1045
+ // ============================================================
1046
+
1047
+ // ============================================================
1048
+ // Scene graph (retained mode)
1049
+ // ============================================================
1050
+
1051
+ // ============================================================
1052
+ // Geometry generation
1053
+ // ============================================================
1054
+
1055
+ // ============================================================
1056
+ // Shadow mapping
1057
+ // ============================================================
1058
+
1059
+ // ============================================================
1060
+ // Post-processing
1061
+ // ============================================================
1062
+
1063
+ // ============================================================
1064
+ // 3D→2D Projection (for UI overlays positioned in 3D space)
1065
+ // ============================================================
1066
+
1067
+
1068
+ // ============================================================
1069
+ // Scene picking (raycasting)
1070
+ // ============================================================
1071
+
1072
+
1073
+ // ============================================================
1074
+ // Thread-safe staging (for async asset loading via Perry threads)
1075
+ // ============================================================
1076
+
1077
+ fn pollster_block_on<F: std::future::Future>(future: F) -> F::Output {
1078
+ use std::task::{Context, Poll, Wake, Waker};
1079
+ use std::pin::Pin;
1080
+ use std::sync::Arc;
1081
+ struct NoopWaker;
1082
+ impl Wake for NoopWaker { fn wake(self: Arc<Self>) {} }
1083
+ let waker = Waker::from(Arc::new(NoopWaker));
1084
+ let mut cx = Context::from_waker(&waker);
1085
+ let mut future = unsafe { Pin::new_unchecked(Box::new(future)) };
1086
+ loop {
1087
+ match future.as_mut().poll(&mut cx) {
1088
+ Poll::Ready(result) => return result,
1089
+ Poll::Pending => std::thread::yield_now(),
1090
+ }
1091
+ }
1092
+ }
1093
+
1094
+
1095
+
1096
+ // Q6: Multi-hit picking
1097
+ // ============================================================
1098
+
1099
+ // ============================================================
1100
+ // Render quality toggles (individual + preset) — ticket 011
1101
+ // Mirror of the macOS FFI surface added in commit 95da6af; previously
1102
+ // macOS-only, now exposed on every native platform so non-macOS builds
1103
+ // don't fail at runtime (missing symbol) when the TS API invokes them.
1104
+ // ============================================================
1105
+
1106
+ // ============================================================
1107
+ // Render scale / upscale / DRS / post-FX / screenshots / impulse
1108
+ // Ports of the macOS FFI surface so the bloom/core TS layer links
1109
+ // cleanly on Linux. EngineState in bloom-shared already exposes the
1110
+ // underlying renderer methods, so these wrappers are platform-agnostic.
1111
+ // ============================================================
1112
+
1113
+
1114
+ // ============================================================
1115
+ // Profiler — CPU phase timings (always available) + GPU timestamps
1116
+ // (when the adapter supports TIMESTAMP_QUERY). Disabled by default.
1117
+ // ============================================================
1118
+
1119
+ // ============================================================
1120
+ // Geisterhand screenshot integration
1121
+ // ============================================================
1122
+
1123
+ /// Register Bloom's GPU-based screenshot capture with perry-geisterhand.
1124
+ /// Mirrors macOS — replaces the platform-default window-grabber with a
1125
+ /// direct wgpu texture readback that works against the same Vulkan/GL
1126
+ /// surface bloom is already drawing into.
1127
+ fn bloom_register_geisterhand_screenshot() {
1128
+ extern "C" {
1129
+ fn perry_geisterhand_register_screenshot_capture(
1130
+ f: extern "C" fn(*mut usize) -> *mut u8,
1131
+ );
1132
+ }
1133
+ unsafe {
1134
+ perry_geisterhand_register_screenshot_capture(bloom_screenshot_capture);
1135
+ }
1136
+ }
1137
+
1138
+ /// Capture the Bloom framebuffer as PNG. Called from the geisterhand pump
1139
+ /// BEFORE end_frame in bloom_end_drawing. The vertices_3d/2d and VP matrix
1140
+ /// from the game loop are still populated; we render to a fresh surface
1141
+ /// texture with screenshot capture, producing the same visual output as
1142
+ /// the real frame.
1143
+ extern "C" fn bloom_screenshot_capture(out_len: *mut usize) -> *mut u8 {
1144
+ let mut eng = engine();
1145
+ let time = eng.get_time() as f32;
1146
+ let delta_time = eng.delta_time as f32;
1147
+ let bloom_shared::engine::EngineState {
1148
+ renderer,
1149
+ scene,
1150
+ profiler,
1151
+ ..
1152
+ } = &mut *eng;
1153
+
1154
+ renderer.screenshot_requested = true;
1155
+ scene.prepare(
1156
+ &renderer.device,
1157
+ &renderer.queue,
1158
+ &renderer.vp_matrix(),
1159
+ &renderer.prev_vp_matrix,
1160
+ renderer.uniform_3d_layout(),
1161
+ // Screenshot capture renders everything the camera might see —
1162
+ // never occlusion-cull a one-shot capture.
1163
+ None,
1164
+ );
1165
+ scene.prepare_materials(renderer);
1166
+ {
1167
+ renderer.material_system_begin_frame(time, delta_time);
1168
+ }
1169
+ renderer.end_frame_with_scene(scene, profiler);
1170
+
1171
+ match renderer.screenshot_data.take() {
1172
+ Some((width, height, rgba)) => {
1173
+ match encode_png(width, height, &rgba) {
1174
+ Some(png_data) => {
1175
+ let len = png_data.len();
1176
+ let ptr = unsafe { libc::malloc(len) as *mut u8 };
1177
+ if ptr.is_null() {
1178
+ unsafe { *out_len = 0; }
1179
+ return std::ptr::null_mut();
1180
+ }
1181
+ unsafe {
1182
+ std::ptr::copy_nonoverlapping(png_data.as_ptr(), ptr, len);
1183
+ *out_len = len;
1184
+ }
1185
+ ptr
1186
+ }
1187
+ None => {
1188
+ unsafe { *out_len = 0; }
1189
+ std::ptr::null_mut()
1190
+ }
1191
+ }
1192
+ }
1193
+ None => {
1194
+ unsafe { *out_len = 0; }
1195
+ std::ptr::null_mut()
1196
+ }
1197
+ }
1198
+ }
1199
+
1200
+ /// Minimal PNG encoder (no external dependency). Matches the macOS
1201
+ /// implementation byte-for-byte so screenshots are identical across
1202
+ /// platforms.
1203
+ fn encode_png(width: u32, height: u32, rgba: &[u8]) -> Option<Vec<u8>> {
1204
+ use std::io::Write;
1205
+
1206
+ let mut png = Vec::new();
1207
+ png.write_all(&[137, 80, 78, 71, 13, 10, 26, 10]).ok()?;
1208
+
1209
+ let mut ihdr = Vec::new();
1210
+ ihdr.extend_from_slice(&width.to_be_bytes());
1211
+ ihdr.extend_from_slice(&height.to_be_bytes());
1212
+ ihdr.push(8);
1213
+ ihdr.push(6);
1214
+ ihdr.push(0);
1215
+ ihdr.push(0);
1216
+ ihdr.push(0);
1217
+ write_png_chunk(&mut png, b"IHDR", &ihdr);
1218
+
1219
+ let row_bytes = (width * 4) as usize;
1220
+ let mut raw = Vec::with_capacity((row_bytes + 1) * height as usize);
1221
+ for y in 0..height as usize {
1222
+ raw.push(0);
1223
+ let start = y * row_bytes;
1224
+ for x in 0..width as usize {
1225
+ let idx = start + x * 4;
1226
+ // wgpu Bgra8UnormSrgb: byte order is B, G, R, A
1227
+ raw.push(rgba[idx + 2]);
1228
+ raw.push(rgba[idx + 1]);
1229
+ raw.push(rgba[idx + 0]);
1230
+ raw.push(255);
1231
+ }
1232
+ }
1233
+
1234
+ let deflated = deflate_store(&raw);
1235
+ write_png_chunk(&mut png, b"IDAT", &deflated);
1236
+ write_png_chunk(&mut png, b"IEND", &[]);
1237
+ Some(png)
1238
+ }
1239
+
1240
+ fn write_png_chunk(out: &mut Vec<u8>, chunk_type: &[u8; 4], data: &[u8]) {
1241
+ let len = data.len() as u32;
1242
+ out.extend_from_slice(&len.to_be_bytes());
1243
+ out.extend_from_slice(chunk_type);
1244
+ out.extend_from_slice(data);
1245
+ let crc = crc32(&[chunk_type.as_slice(), data].concat());
1246
+ out.extend_from_slice(&crc.to_be_bytes());
1247
+ }
1248
+
1249
+ fn crc32(data: &[u8]) -> u32 {
1250
+ let mut crc: u32 = 0xFFFFFFFF;
1251
+ for &byte in data {
1252
+ crc ^= byte as u32;
1253
+ for _ in 0..8 {
1254
+ if crc & 1 != 0 {
1255
+ crc = (crc >> 1) ^ 0xEDB88320;
1256
+ } else {
1257
+ crc >>= 1;
1258
+ }
1259
+ }
1260
+ }
1261
+ !crc
1262
+ }
1263
+
1264
+ fn deflate_store(data: &[u8]) -> Vec<u8> {
1265
+ let mut out = Vec::new();
1266
+ out.push(0x78);
1267
+ out.push(0x01);
1268
+
1269
+ let mut remaining = data.len();
1270
+ let mut offset = 0;
1271
+ while remaining > 0 {
1272
+ let block_size = remaining.min(65535);
1273
+ let is_last = remaining <= 65535;
1274
+ out.push(if is_last { 1 } else { 0 });
1275
+ let len = block_size as u16;
1276
+ let nlen = !len;
1277
+ out.extend_from_slice(&len.to_le_bytes());
1278
+ out.extend_from_slice(&nlen.to_le_bytes());
1279
+ out.extend_from_slice(&data[offset..offset + block_size]);
1280
+ offset += block_size;
1281
+ remaining -= block_size;
1282
+ }
1283
+
1284
+ let adler = adler32(data);
1285
+ out.extend_from_slice(&adler.to_be_bytes());
1286
+ out
1287
+ }
1288
+
1289
+ fn adler32(data: &[u8]) -> u32 {
1290
+ let mut a: u32 = 1;
1291
+ let mut b: u32 = 0;
1292
+ for &byte in data {
1293
+ a = (a + byte as u32) % 65521;
1294
+ b = (b + a) % 65521;
1295
+ }
1296
+ (b << 16) | a
1297
+ }
1298
+
1299
+ // ============================================================
1300
+ // Physics (Jolt 5.x) — FFI surface generated from shared macro
1301
+ // ============================================================
1302
+
1303
+ #[cfg(feature = "jolt")]
1304
+ struct JoltEngineGuard {
1305
+ engine: MutexGuard<'static, EngineState>,
1306
+ }
1307
+
1308
+ #[cfg(feature = "jolt")]
1309
+ impl Deref for JoltEngineGuard {
1310
+ type Target = bloom_shared::physics_jolt::JoltPhysics;
1311
+
1312
+ fn deref(&self) -> &Self::Target {
1313
+ &self.engine.jolt
1314
+ }
1315
+ }
1316
+
1317
+ #[cfg(feature = "jolt")]
1318
+ impl DerefMut for JoltEngineGuard {
1319
+ fn deref_mut(&mut self) -> &mut Self::Target {
1320
+ &mut self.engine.jolt
1321
+ }
1322
+ }
1323
+
1324
+ #[cfg(feature = "jolt")]
1325
+ #[inline]
1326
+ fn bloom_jolt_ffi_physics() -> JoltEngineGuard {
1327
+ JoltEngineGuard { engine: engine() }
1328
+ }
1329
+
1330
+ #[cfg(feature = "jolt")]
1331
+ bloom_shared::define_physics_ffi!();