@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,1486 @@
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
+ use bloom_shared::audio::{parse_wav, parse_ogg, parse_mp3};
5
+
6
+ use std::sync::OnceLock;
7
+
8
+ static mut ENGINE: OnceLock<EngineState> = OnceLock::new();
9
+
10
+ /// True when the engine renders into a host-provided child window (a Perry UI
11
+ /// `BloomView`) rather than a Bloom-owned top-level window. In embedded mode
12
+ /// the host owns the Win32 message loop, so `bloom_begin_drawing` must not pump
13
+ /// messages itself and `bloom_window_should_close` always reports "stay open".
14
+ #[cfg(windows)]
15
+ static mut EMBEDDED: bool = false;
16
+
17
+ fn engine() -> &'static mut EngineState {
18
+ unsafe { ENGINE.get_mut().expect("Engine not initialized") }
19
+ }
20
+ /// Asset-path hook for define_core_ffi! — identity on desktop, where game
21
+ /// asset paths are valid relative to the working directory.
22
+ fn bloom_resolve_asset_path(path: &str) -> std::borrow::Cow<'_, str> {
23
+ std::borrow::Cow::Borrowed(path)
24
+ }
25
+
26
+ // The full shared (non-physics) FFI surface. See bloom_shared::ffi_core
27
+ // docs for the contract; tools/validate-ffi.js checks parity in CI.
28
+ bloom_shared::define_core_ffi!();
29
+
30
+
31
+ /// Map Win32 virtual key code to Bloom key code.
32
+ fn map_keycode(vk: u32) -> usize {
33
+ match vk {
34
+ 0x41..=0x5A => vk as usize, // A-Z map directly (65-90)
35
+ 0x30..=0x39 => vk as usize, // 0-9 map directly (48-57)
36
+ 0x70..=0x7B => (vk - 0x70 + 112) as usize, // F1-F12
37
+ 0x26 => 256, // VK_UP
38
+ 0x28 => 257, // VK_DOWN
39
+ 0x25 => 258, // VK_LEFT
40
+ 0x27 => 259, // VK_RIGHT
41
+ 0x20 => 32, // VK_SPACE
42
+ 0x0D => 265, // VK_RETURN → Bloom ENTER
43
+ 0x1B => 27, // VK_ESCAPE
44
+ 0x09 => 9, // VK_TAB
45
+ 0x08 => 8, // VK_BACK
46
+ 0x2E => 127, // VK_DELETE
47
+ 0x2D => 260, // VK_INSERT
48
+ 0x24 => 261, // VK_HOME
49
+ 0x23 => 262, // VK_END
50
+ 0x21 => 263, // VK_PRIOR (Page Up)
51
+ 0x22 => 264, // VK_NEXT (Page Down)
52
+ 0xA0 => 280, // VK_LSHIFT
53
+ 0xA1 => 281, // VK_RSHIFT
54
+ 0xA2 => 282, // VK_LCONTROL
55
+ 0xA3 => 283, // VK_RCONTROL
56
+ 0xA4 => 284, // VK_LMENU (Left Alt)
57
+ 0xA5 => 285, // VK_RMENU (Right Alt)
58
+ 0x5B => 286, // VK_LWIN
59
+ 0x5C => 287, // VK_RWIN
60
+ _ => 0,
61
+ }
62
+ }
63
+
64
+ // Windows reports the GENERIC modifier VKs — VK_SHIFT (0x10), VK_CONTROL (0x11),
65
+ // VK_MENU (0x12) — in WM_KEYDOWN/UP for BOTH the left and right keys. The
66
+ // side-specific codes (VK_LSHIFT 0xA0, etc.) come back only from GetKeyState,
67
+ // never in the message wParam. map_keycode knows only the side-specific codes,
68
+ // so a raw generic VK mapped to 0 and was dropped: every Shift and Ctrl press
69
+ // vanished, which is why sprint (Shift) and dodge (Ctrl) silently never fired on
70
+ // Windows. Recover the side from lParam — scancode for Shift (LShift = 0x2A,
71
+ // RShift = 0x36), the extended-key bit for Ctrl/Alt (the right-hand ones are
72
+ // flagged extended).
73
+ fn resolve_modifier_vk(vk: u32, lparam: isize) -> u32 {
74
+ let scancode = ((lparam >> 16) & 0xFF) as u32;
75
+ let extended = (lparam >> 24) & 1 != 0;
76
+ match vk {
77
+ 0x10 => if scancode == 0x36 { 0xA1 } else { 0xA0 }, // VK_SHIFT -> R/L
78
+ 0x11 => if extended { 0xA3 } else { 0xA2 }, // VK_CONTROL -> R/L
79
+ 0x12 => if extended { 0xA5 } else { 0xA4 }, // VK_MENU -> R/L
80
+ _ => vk,
81
+ }
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Crash reporting (title-freeze / EN-020 investigation): the game was dying
86
+ // with empty stderr, no WER event, and no dump — i.e. invisibly. Catch
87
+ // unhandled SEH exceptions, print code + module-relative address
88
+ // (symbolizable against main.pdb via llvm-symbolizer), and write our own
89
+ // minidump. Fast-fail (0xC0000409) bypasses this filter by design, so a
90
+ // death with no output at all narrows to fail-fast / TerminateProcess.
91
+ #[cfg(windows)]
92
+ mod crash_report {
93
+ use std::os::windows::io::AsRawHandle;
94
+ use std::sync::atomic::{AtomicBool, Ordering};
95
+ use windows::core::{s, w};
96
+ use windows::Win32::Foundation::BOOL;
97
+ use windows::Win32::System::Diagnostics::Debug::{
98
+ SetUnhandledExceptionFilter, EXCEPTION_POINTERS, MINIDUMP_EXCEPTION_INFORMATION,
99
+ };
100
+ use windows::Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress, LoadLibraryW};
101
+ use windows::Win32::System::Threading::{
102
+ GetCurrentProcess, GetCurrentProcessId, GetCurrentThreadId,
103
+ };
104
+
105
+ // dbghelp.dll is loaded at crash time — its import lib is not on
106
+ // perry's link line, and adding manifest libs would force a
107
+ // .perry-cache clear for every consumer of the engine.
108
+ type MiniDumpWriteDumpFn = unsafe extern "system" fn(
109
+ hprocess: *mut core::ffi::c_void,
110
+ processid: u32,
111
+ hfile: *mut core::ffi::c_void,
112
+ dumptype: i32,
113
+ exceptionparam: *const MINIDUMP_EXCEPTION_INFORMATION,
114
+ userstreamparam: *const core::ffi::c_void,
115
+ callbackparam: *const core::ffi::c_void,
116
+ ) -> i32;
117
+
118
+ // MiniDumpNormal | MiniDumpWithIndirectlyReferencedMemory | MiniDumpWithThreadInfo
119
+ const DUMP_FLAGS: i32 = 0x0000_0040 | 0x0000_1000;
120
+
121
+ static INSTALLED: AtomicBool = AtomicBool::new(false);
122
+
123
+ pub fn install() {
124
+ if INSTALLED.swap(true, Ordering::SeqCst) {
125
+ return;
126
+ }
127
+ unsafe {
128
+ SetUnhandledExceptionFilter(Some(filter));
129
+ }
130
+ eprintln!("bloom: crash filter installed (dumps -> tools/.testout/dumps)");
131
+ }
132
+
133
+ unsafe extern "system" fn filter(info: *const EXCEPTION_POINTERS) -> i32 {
134
+ let (code, addr) = {
135
+ let mut c = 0u32;
136
+ let mut a = 0usize;
137
+ if !info.is_null() {
138
+ let rec = (*info).ExceptionRecord;
139
+ if !rec.is_null() {
140
+ c = (*rec).ExceptionCode.0 as u32;
141
+ a = (*rec).ExceptionAddress as usize;
142
+ }
143
+ }
144
+ (c, a)
145
+ };
146
+ let base = GetModuleHandleW(None).map(|m| m.0 as usize).unwrap_or(0);
147
+ let rel = addr.wrapping_sub(base);
148
+ eprintln!("bloom: FATAL unhandled exception {code:#010x} at {addr:#x} (main.exe+{rel:#x})");
149
+
150
+ let _ = std::fs::create_dir_all("tools/.testout/dumps");
151
+ let stamp = std::time::SystemTime::now()
152
+ .duration_since(std::time::UNIX_EPOCH)
153
+ .map(|d| d.as_secs())
154
+ .unwrap_or(0);
155
+ let path = format!(
156
+ "tools/.testout/dumps/crash_main_{}_{stamp:x}.dmp",
157
+ GetCurrentProcessId()
158
+ );
159
+ let write_dump = LoadLibraryW(w!("dbghelp.dll"))
160
+ .ok()
161
+ .and_then(|h| GetProcAddress(h, s!("MiniDumpWriteDump")))
162
+ .map(|p| std::mem::transmute::<_, MiniDumpWriteDumpFn>(p));
163
+ match (std::fs::File::create(&path), write_dump) {
164
+ (Ok(f), Some(dump_fn)) => {
165
+ let exc = MINIDUMP_EXCEPTION_INFORMATION {
166
+ ThreadId: GetCurrentThreadId(),
167
+ ExceptionPointers: info as *mut EXCEPTION_POINTERS,
168
+ ClientPointers: BOOL(0),
169
+ };
170
+ let ok = dump_fn(
171
+ GetCurrentProcess().0,
172
+ GetCurrentProcessId(),
173
+ f.as_raw_handle() as *mut core::ffi::c_void,
174
+ DUMP_FLAGS,
175
+ &exc,
176
+ core::ptr::null(),
177
+ core::ptr::null(),
178
+ );
179
+ if ok != 0 {
180
+ eprintln!("bloom: minidump written: {path}");
181
+ } else {
182
+ eprintln!("bloom: minidump FAILED (MiniDumpWriteDump returned FALSE)");
183
+ }
184
+ }
185
+ (Err(e), _) => eprintln!("bloom: minidump file create failed: {e}"),
186
+ (_, None) => eprintln!("bloom: minidump unavailable (dbghelp load failed)"),
187
+ }
188
+ 1 // EXCEPTION_EXECUTE_HANDLER — let the process die
189
+ }
190
+ }
191
+
192
+ // Win32 windowing implementation
193
+ #[cfg(windows)]
194
+ mod win32 {
195
+ use super::*;
196
+ use windows::Win32::UI::WindowsAndMessaging::*;
197
+ use windows::Win32::UI::HiDpi::*;
198
+ use windows::Win32::Foundation::*;
199
+ use windows::Win32::System::LibraryLoader::GetModuleHandleW;
200
+ use windows::Win32::Graphics::Gdi::*;
201
+ use windows::core::*;
202
+ use raw_window_handle::{RawWindowHandle, Win32WindowHandle, RawDisplayHandle, WindowsDisplayHandle};
203
+
204
+ static mut HWND_GLOBAL: Option<HWND> = None;
205
+
206
+ /// The Bloom-owned top-level window, for platform features that need an
207
+ /// owner (dialogs, SetWindowTextW, clipboard). None in embedded mode.
208
+ pub fn main_hwnd() -> Option<HWND> {
209
+ unsafe { HWND_GLOBAL }
210
+ }
211
+ static mut IS_FULLSCREEN: bool = false;
212
+ static mut WINDOWED_STYLE: u32 = 0;
213
+ static mut WINDOWED_RECT: RECT = RECT { left: 0, top: 0, right: 0, bottom: 0 };
214
+
215
+ pub fn set_fullscreen(fullscreen: bool) {
216
+ unsafe {
217
+ let Some(hwnd) = HWND_GLOBAL else { return };
218
+
219
+ if fullscreen && !IS_FULLSCREEN {
220
+ // Save current style and window rect for restore
221
+ WINDOWED_STYLE = GetWindowLongW(hwnd, GWL_STYLE) as u32;
222
+ let _ = GetWindowRect(hwnd, &mut WINDOWED_RECT);
223
+
224
+ // Get monitor dimensions
225
+ let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
226
+ let mut mi: MONITORINFO = std::mem::zeroed();
227
+ mi.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
228
+ let _ = GetMonitorInfoW(monitor, &mut mi);
229
+
230
+ // Set borderless fullscreen
231
+ SetWindowLongW(hwnd, GWL_STYLE, (WS_POPUP | WS_VISIBLE).0 as i32);
232
+ let _ = SetWindowPos(
233
+ hwnd, HWND_TOP,
234
+ mi.rcMonitor.left, mi.rcMonitor.top,
235
+ mi.rcMonitor.right - mi.rcMonitor.left,
236
+ mi.rcMonitor.bottom - mi.rcMonitor.top,
237
+ SWP_FRAMECHANGED | SWP_NOOWNERZORDER,
238
+ );
239
+ IS_FULLSCREEN = true;
240
+ } else if !fullscreen && IS_FULLSCREEN {
241
+ // Restore windowed mode
242
+ SetWindowLongW(hwnd, GWL_STYLE, WINDOWED_STYLE as i32);
243
+ let _ = SetWindowPos(
244
+ hwnd, None,
245
+ WINDOWED_RECT.left, WINDOWED_RECT.top,
246
+ WINDOWED_RECT.right - WINDOWED_RECT.left,
247
+ WINDOWED_RECT.bottom - WINDOWED_RECT.top,
248
+ SWP_FRAMECHANGED | SWP_NOOWNERZORDER | SWP_NOZORDER,
249
+ );
250
+ IS_FULLSCREEN = false;
251
+ }
252
+ }
253
+ }
254
+
255
+ pub fn toggle_fullscreen() {
256
+ unsafe { set_fullscreen(!IS_FULLSCREEN); }
257
+ }
258
+
259
+ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
260
+ match msg {
261
+ WM_CLOSE => {
262
+ // Diagnostic (title-freeze investigation): who closes us?
263
+ eprintln!("bloom: WM_CLOSE received");
264
+ DefWindowProcW(hwnd, msg, wparam, lparam)
265
+ }
266
+ WM_DESTROY => {
267
+ eprintln!("bloom: WM_DESTROY — should_close set, main loop will exit cleanly");
268
+ PostQuitMessage(0);
269
+ if let Some(eng) = ENGINE.get_mut() {
270
+ eng.should_close = true;
271
+ }
272
+ LRESULT(0)
273
+ }
274
+ WM_KEYDOWN => {
275
+ let bloom_key = map_keycode(resolve_modifier_vk(wparam.0 as u32, lparam.0));
276
+ if bloom_key > 0 {
277
+ if let Some(eng) = ENGINE.get_mut() {
278
+ eng.input.set_key_down(bloom_key);
279
+ // lParam bit 30 = previous key state: 1 means this is
280
+ // an OS auto-repeat, surfaced as its own isKeyRepeated
281
+ // edge (isKeyPressed stays initial-press-only).
282
+ if (lparam.0 >> 30) & 1 == 1 {
283
+ eng.input.queue_key_repeat(bloom_key);
284
+ }
285
+ }
286
+ }
287
+ // F12 — native screenshot hotkey. Perry currently drops
288
+ // the game-side takeScreenshot FFI call entirely
289
+ // (PerryTS/perry#6087), so the capture is triggered here
290
+ // where no compiler sits in the path. Initial press only
291
+ // (lparam bit 30 = previous key state) so holding the key
292
+ // doesn't machine-gun PNGs. The file lands in the working
293
+ // directory with a timestamped name; the readback runs at
294
+ // this frame's end_frame.
295
+ if wparam.0 as u32 == 0x7B && (lparam.0 as u32 >> 30) & 1 == 0 {
296
+ if let Some(eng) = ENGINE.get_mut() {
297
+ let ms = std::time::SystemTime::now()
298
+ .duration_since(std::time::UNIX_EPOCH)
299
+ .map(|d| d.as_millis())
300
+ .unwrap_or(0);
301
+ let path = format!("screenshot_{}.png", ms);
302
+ eprintln!("bloom: F12 screenshot -> {}", path);
303
+ eng.renderer.screenshot_requested = true;
304
+ eng.renderer.pending_screenshot_path = Some(path);
305
+ }
306
+ }
307
+ DefWindowProcW(hwnd, msg, wparam, lparam)
308
+ }
309
+ WM_KEYUP => {
310
+ let bloom_key = map_keycode(resolve_modifier_vk(wparam.0 as u32, lparam.0));
311
+ if bloom_key > 0 {
312
+ if let Some(eng) = ENGINE.get_mut() {
313
+ eng.input.set_key_up(bloom_key);
314
+ }
315
+ }
316
+ DefWindowProcW(hwnd, msg, wparam, lparam)
317
+ }
318
+ WM_MOUSEMOVE => {
319
+ // lParam carries PHYSICAL client pixels. The rest of the
320
+ // engine — screenWidth, all 2D drawing, picking — works in
321
+ // LOGICAL units (see the WM_SIZE handler), so divide by the
322
+ // window's DPI scale before handing coordinates in. Without
323
+ // this, every hit-test on a scaled display (125%/150%…) lands
324
+ // scale× away from the pointer — buttons near the top-left
325
+ // almost work, everything else misses.
326
+ let x = (lparam.0 & 0xFFFF) as i16 as f64;
327
+ let y = ((lparam.0 >> 16) & 0xFFFF) as i16 as f64;
328
+ let scale = dpi_scale(hwnd);
329
+ if let Some(eng) = ENGINE.get_mut() {
330
+ if eng.input.cursor_disabled {
331
+ // FPS-style capture: accumulate the movement away from
332
+ // the window centre as a raw delta, then snap the OS
333
+ // cursor back to centre so look never hits the screen
334
+ // edge. (begin_frame consumes raw_delta when the cursor
335
+ // is disabled.) The recenter generates another
336
+ // WM_MOUSEMOVE at the centre with zero delta — no loop.
337
+ // Centre math and SetCursorPos stay PHYSICAL (that is
338
+ // what GetClientRect/ClientToScreen speak); only the
339
+ // delta handed to the engine converts to logical, so
340
+ // look sensitivity matches macOS point-deltas.
341
+ let mut rect = RECT::default();
342
+ let _ = GetClientRect(hwnd, &mut rect);
343
+ let cx = ((rect.right - rect.left) / 2) as f64;
344
+ let cy = ((rect.bottom - rect.top) / 2) as f64;
345
+ let dx = x - cx;
346
+ let dy = y - cy;
347
+ if dx != 0.0 || dy != 0.0 {
348
+ eng.input.accumulate_mouse_delta(dx / scale, dy / scale);
349
+ let mut pt = POINT { x: cx as i32, y: cy as i32 };
350
+ let _ = ClientToScreen(hwnd, &mut pt);
351
+ let _ = SetCursorPos(pt.x, pt.y);
352
+ }
353
+ } else {
354
+ eng.input.set_mouse_position(x / scale, y / scale);
355
+ }
356
+ }
357
+ DefWindowProcW(hwnd, msg, wparam, lparam)
358
+ }
359
+ WM_LBUTTONDOWN => {
360
+ if let Some(eng) = ENGINE.get_mut() {
361
+ eng.input.set_mouse_button_down(0);
362
+ }
363
+ DefWindowProcW(hwnd, msg, wparam, lparam)
364
+ }
365
+ WM_LBUTTONUP => {
366
+ if let Some(eng) = ENGINE.get_mut() {
367
+ eng.input.set_mouse_button_up(0);
368
+ }
369
+ DefWindowProcW(hwnd, msg, wparam, lparam)
370
+ }
371
+ WM_RBUTTONDOWN => {
372
+ if let Some(eng) = ENGINE.get_mut() {
373
+ eng.input.set_mouse_button_down(1);
374
+ }
375
+ DefWindowProcW(hwnd, msg, wparam, lparam)
376
+ }
377
+ WM_RBUTTONUP => {
378
+ if let Some(eng) = ENGINE.get_mut() {
379
+ eng.input.set_mouse_button_up(1);
380
+ }
381
+ DefWindowProcW(hwnd, msg, wparam, lparam)
382
+ }
383
+ 0x0005 /* WM_SIZE */ => {
384
+ // lParam carries the new client-area size in *physical*
385
+ // pixels (Per-Monitor-Aware-V2). Derive logical via
386
+ // current DPI so the rest of the engine sees the same
387
+ // logical/physical split macOS does.
388
+ let phys_w = (lparam.0 & 0xFFFF) as u32;
389
+ let phys_h = ((lparam.0 >> 16) & 0xFFFF) as u32;
390
+ if phys_w > 0 && phys_h > 0 {
391
+ if let Some(eng) = ENGINE.get_mut() {
392
+ if phys_w != eng.renderer.physical_width()
393
+ || phys_h != eng.renderer.physical_height()
394
+ {
395
+ let scale = dpi_scale(hwnd);
396
+ let log_w = ((phys_w as f64) / scale).round() as u32;
397
+ let log_h = ((phys_h as f64) / scale).round() as u32;
398
+ eng.renderer.resize(phys_w, phys_h, log_w, log_h);
399
+ }
400
+ }
401
+ }
402
+ DefWindowProcW(hwnd, msg, wparam, lparam)
403
+ }
404
+ 0x02E0 /* WM_DPICHANGED */ => {
405
+ // The window dragged onto a different-DPI monitor.
406
+ // lParam holds a suggested RECT — Windows wants us to
407
+ // accept this geometry to keep the window's apparent
408
+ // size constant across the DPI change. The follow-up
409
+ // WM_SIZE handles the renderer resize.
410
+ let suggested = &*(lparam.0 as *const RECT);
411
+ let _ = SetWindowPos(
412
+ hwnd, None,
413
+ suggested.left, suggested.top,
414
+ suggested.right - suggested.left,
415
+ suggested.bottom - suggested.top,
416
+ SWP_NOZORDER | SWP_NOACTIVATE,
417
+ );
418
+ LRESULT(0)
419
+ }
420
+ _ => DefWindowProcW(hwnd, msg, wparam, lparam),
421
+ }
422
+ }
423
+
424
+ /// Returns (hwnd, physical_w, physical_h). Caller's `width`/`height`
425
+ /// are *logical* — on a HiDPI monitor with scale 2.0 the window
426
+ /// will appear logically the right size while its client area is
427
+ /// scaled-up physical pixels for the renderer to fill.
428
+ pub fn create_window(width: f64, height: f64, title: &str) -> (HWND, u32, u32) {
429
+ unsafe {
430
+ // Per-Monitor-Aware-V2: the window's DPI tracks the monitor
431
+ // it's currently on, and Windows fires WM_DPICHANGED when
432
+ // it moves between monitors of different DPI. Without this
433
+ // call, Win32 silently virtualises us to 96 DPI on HiDPI
434
+ // displays — a 4K monitor would render at ~2560×1440.
435
+ // Safe to call early; ignored on pre-Win10 1703.
436
+ let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
437
+
438
+ let hmodule = GetModuleHandleW(None).unwrap();
439
+ let hinstance: HINSTANCE = hmodule.into();
440
+ let class_name = w!("BloomWindowClass");
441
+
442
+ let wc = WNDCLASSEXW {
443
+ cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
444
+ style: CS_HREDRAW | CS_VREDRAW,
445
+ lpfnWndProc: Some(wndproc),
446
+ hInstance: hinstance,
447
+ lpszClassName: class_name,
448
+ hCursor: LoadCursorW(None, IDC_ARROW).unwrap(),
449
+ ..Default::default()
450
+ };
451
+ RegisterClassExW(&wc);
452
+
453
+ let title_wide: Vec<u16> = title.encode_utf16().chain(std::iter::once(0)).collect();
454
+
455
+ // Initial window size in physical pixels. We don't have a
456
+ // window handle yet, so use the system DPI — close enough,
457
+ // and WM_DPICHANGED will resize on the first move if the
458
+ // user lands on a different-DPI monitor.
459
+ let system_dpi = GetDpiForSystem().max(96);
460
+ let scale = system_dpi as f64 / 96.0;
461
+ let phys_w = (width * scale).round() as i32;
462
+ let phys_h = (height * scale).round() as i32;
463
+
464
+ let hwnd = CreateWindowExW(
465
+ WINDOW_EX_STYLE::default(),
466
+ class_name,
467
+ PCWSTR(title_wide.as_ptr()),
468
+ WS_OVERLAPPEDWINDOW | WS_VISIBLE,
469
+ CW_USEDEFAULT, CW_USEDEFAULT,
470
+ phys_w, phys_h,
471
+ None, None, Some(&hinstance), None,
472
+ ).unwrap();
473
+
474
+ ShowWindow(hwnd, SW_SHOW);
475
+ HWND_GLOBAL = Some(hwnd);
476
+
477
+ // After the window exists, query the actual client-area
478
+ // size. Includes whatever DWM trimmed for borders and
479
+ // reflects this monitor's DPI rather than the system one.
480
+ let mut rect = RECT::default();
481
+ let _ = GetClientRect(hwnd, &mut rect);
482
+ let actual_w = (rect.right - rect.left).max(1) as u32;
483
+ let actual_h = (rect.bottom - rect.top).max(1) as u32;
484
+ (hwnd, actual_w, actual_h)
485
+ }
486
+ }
487
+
488
+ /// Current per-monitor DPI scale for this window (1.0 on a 96-DPI
489
+ /// monitor, 2.0 on a Retina-class 192-DPI monitor, etc.). Falls
490
+ /// back to 1.0 if the API is missing (pre-Win10 1607).
491
+ pub fn dpi_scale(hwnd: HWND) -> f64 {
492
+ unsafe {
493
+ let dpi = GetDpiForWindow(hwnd);
494
+ if dpi == 0 { 1.0 } else { dpi as f64 / 96.0 }
495
+ }
496
+ }
497
+
498
+ pub fn poll_events() {
499
+ unsafe {
500
+ let mut msg = MSG::default();
501
+ while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).into() {
502
+ TranslateMessage(&msg);
503
+ DispatchMessageW(&msg);
504
+ }
505
+ }
506
+ }
507
+
508
+ // ---- Embedded mode (Perry UI BloomView host window) ----
509
+ //
510
+ // When Bloom renders into a Perry UI child window, that window already has
511
+ // Perry's own WNDPROC. We classic-subclass it so Bloom sees the WM_SIZE /
512
+ // keyboard / mouse it needs (its own `wndproc` above never runs for a
513
+ // foreign-class window), then chain to the original proc. The host's
514
+ // message loop dispatches these — Bloom never pumps in embedded mode.
515
+ static mut EMBED_ORIG_WNDPROC: isize = 0;
516
+
517
+ unsafe extern "system" fn embedded_wndproc(
518
+ hwnd: HWND,
519
+ msg: u32,
520
+ wparam: WPARAM,
521
+ lparam: LPARAM,
522
+ ) -> LRESULT {
523
+ match msg {
524
+ 0x0005 /* WM_SIZE */ => {
525
+ let phys_w = (lparam.0 & 0xFFFF) as u32;
526
+ let phys_h = ((lparam.0 >> 16) & 0xFFFF) as u32;
527
+ if phys_w > 0 && phys_h > 0 {
528
+ if let Some(eng) = ENGINE.get_mut() {
529
+ if phys_w != eng.renderer.physical_width()
530
+ || phys_h != eng.renderer.physical_height()
531
+ {
532
+ let scale = dpi_scale(hwnd);
533
+ let log_w = ((phys_w as f64) / scale).round() as u32;
534
+ let log_h = ((phys_h as f64) / scale).round() as u32;
535
+ eng.renderer.resize(phys_w, phys_h, log_w, log_h);
536
+ }
537
+ }
538
+ }
539
+ }
540
+ WM_KEYDOWN => {
541
+ let k = map_keycode(resolve_modifier_vk(wparam.0 as u32, lparam.0));
542
+ if k > 0 {
543
+ if let Some(eng) = ENGINE.get_mut() {
544
+ eng.input.set_key_down(k);
545
+ if (lparam.0 >> 30) & 1 == 1 { eng.input.queue_key_repeat(k); }
546
+ }
547
+ }
548
+ }
549
+ WM_KEYUP => {
550
+ let k = map_keycode(resolve_modifier_vk(wparam.0 as u32, lparam.0));
551
+ if k > 0 { if let Some(eng) = ENGINE.get_mut() { eng.input.set_key_up(k); } }
552
+ }
553
+ WM_MOUSEMOVE => {
554
+ // Physical -> logical, same as the standalone window proc.
555
+ let scale = dpi_scale(hwnd);
556
+ let x = ((lparam.0 & 0xFFFF) as i16 as f64) / scale;
557
+ let y = (((lparam.0 >> 16) & 0xFFFF) as i16 as f64) / scale;
558
+ if let Some(eng) = ENGINE.get_mut() { eng.input.set_mouse_position(x, y); }
559
+ }
560
+ WM_LBUTTONDOWN => { if let Some(eng) = ENGINE.get_mut() { eng.input.set_mouse_button_down(0); } }
561
+ WM_LBUTTONUP => { if let Some(eng) = ENGINE.get_mut() { eng.input.set_mouse_button_up(0); } }
562
+ WM_RBUTTONDOWN => { if let Some(eng) = ENGINE.get_mut() { eng.input.set_mouse_button_down(1); } }
563
+ WM_RBUTTONUP => { if let Some(eng) = ENGINE.get_mut() { eng.input.set_mouse_button_up(1); } }
564
+ _ => {}
565
+ }
566
+ let orig: WNDPROC = std::mem::transmute(EMBED_ORIG_WNDPROC);
567
+ CallWindowProcW(orig, hwnd, msg, wparam, lparam)
568
+ }
569
+
570
+ /// Subclass a host-provided child window so Bloom receives resize/input.
571
+ pub unsafe fn attach_subclass(hwnd: HWND) {
572
+ let prev = SetWindowLongPtrW(hwnd, GWLP_WNDPROC, embedded_wndproc as usize as isize);
573
+ EMBED_ORIG_WNDPROC = prev;
574
+ HWND_GLOBAL = Some(hwnd);
575
+ }
576
+ }
577
+
578
+ #[no_mangle]
579
+ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u8, fullscreen: f64) {
580
+ let title = str_from_header(title_ptr);
581
+
582
+ #[cfg(windows)]
583
+ {
584
+ crash_report::install();
585
+ let (hwnd, phys_w, phys_h) = win32::create_window(width, height, title);
586
+ unsafe { init_engine_for_hwnd(hwnd, width as u32, height as u32, phys_w, phys_h); }
587
+ if fullscreen != 0.0 {
588
+ win32::set_fullscreen(true);
589
+ }
590
+ }
591
+
592
+ #[cfg(not(windows))]
593
+ {
594
+ let _ = (width, height, title, fullscreen);
595
+ panic!("bloom-windows can only run on Windows");
596
+ }
597
+ }
598
+
599
+ /// Build the wgpu surface + engine on an existing HWND (top-level window or
600
+ /// host-provided child). Shared by `bloom_init_window` (Bloom owns the window)
601
+ /// and `bloom_attach_hwnd` (a Perry UI `BloomView` child window). `logical_*`
602
+ /// are DPI-independent sizes; `phys_*` are the surface's physical client size.
603
+ #[cfg(windows)]
604
+ unsafe fn init_engine_for_hwnd(
605
+ hwnd: windows::Win32::Foundation::HWND,
606
+ logical_w: u32,
607
+ logical_h: u32,
608
+ phys_w: u32,
609
+ phys_h: u32,
610
+ ) {
611
+ // Compile shaders with DXC, not FXC.
612
+ //
613
+ // This is not a nicety. wgpu's DX12 backend reports the adapter's
614
+ // shader model as min(device, compiler), and FXC — its default — caps
615
+ // that at 5.1. Hardware ray query requires 6.5 (wgpu-hal
616
+ // dx12/adapter.rs: `supports_ray_tracing`), so with FXC,
617
+ // EXPERIMENTAL_RAY_QUERY is never exposed on DX12 on *any* GPU, no
618
+ // matter how capable. Lumen then silently takes its software path and
619
+ // the frame quietly loses its hardware-traced GI. That is what the
620
+ // `ray_query=false` in the boot line has been telling us.
621
+ //
622
+ // DXC is loaded at runtime from `dxcompiler.dll` + `dxil.dll`. wgpu's
623
+ // `static-dxc` feature would avoid the DLLs but needs MSVC's ATL,
624
+ // which is not part of a default toolchain install. Both DLLs ship
625
+ // with the Windows SDK; `tools/fetch-dxc.ps1` copies them next to the
626
+ // binary. If they are missing, wgpu falls back to FXC on its own — we
627
+ // lose HW ray query, exactly as before, and nothing else breaks.
628
+ let mut backend_options = wgpu::BackendOptions::default();
629
+ backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::DynamicDxc {
630
+ dxc_path: String::from("dxcompiler.dll"),
631
+ };
632
+
633
+ let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
634
+ backends: wgpu::Backends::DX12 | wgpu::Backends::VULKAN,
635
+ backend_options,
636
+ ..wgpu::InstanceDescriptor::new_without_display_handle()
637
+ });
638
+
639
+ let surface = unsafe {
640
+ let mut handle = raw_window_handle::Win32WindowHandle::new(
641
+ std::num::NonZeroIsize::new(hwnd.0 as isize).unwrap()
642
+ );
643
+ let raw = raw_window_handle::RawWindowHandle::Win32(handle);
644
+ instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle {
645
+ raw_display_handle: Some(raw_window_handle::RawDisplayHandle::Windows(
646
+ raw_window_handle::WindowsDisplayHandle::new()
647
+ )),
648
+ raw_window_handle: raw,
649
+ }).expect("Failed to create surface")
650
+ };
651
+
652
+ // Pick the adapter that can actually trace rays.
653
+ //
654
+ // We used to take whatever `request_adapter` handed back, which on
655
+ // Windows means DX12 — and wgpu's DX12 backend only reports
656
+ // EXPERIMENTAL_RAY_QUERY when the driver advertises D3D12 raytracing
657
+ // tier 1.1. The same GPU under Vulkan can expose VK_KHR_ray_query
658
+ // when DX12 does not. The result was silent: Lumen's hardware trace
659
+ // was never selected, the software SDF path ran instead, and nobody
660
+ // saw a reason why. So enumerate the candidates, say out loud what
661
+ // each one offers, and prefer one that supports ray query.
662
+ //
663
+ // BLOOM_FORCE_SW_GI keeps its meaning: it also stops us from picking
664
+ // a backend *for* ray tracing, so the software path can be tested on
665
+ // hardware that would otherwise take the fast route.
666
+ let want_rt = !std::env::var("BLOOM_FORCE_SW_GI")
667
+ .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
668
+ .unwrap_or(false);
669
+
670
+ let candidates = pollster_block_on(
671
+ instance.enumerate_adapters(wgpu::Backends::DX12 | wgpu::Backends::VULKAN),
672
+ );
673
+ let mut rt_adapter: Option<wgpu::Adapter> = None;
674
+ for cand in candidates {
675
+ let info = cand.get_info();
676
+ let surf_ok = cand.is_surface_supported(&surface);
677
+ let has_rt = cand
678
+ .features()
679
+ .contains(wgpu::Features::EXPERIMENTAL_RAY_QUERY);
680
+ eprintln!(
681
+ "bloom: candidate '{}' ({:?}), ray_query={}, surface={}",
682
+ info.name, info.backend, has_rt, surf_ok,
683
+ );
684
+ if want_rt && has_rt && surf_ok && rt_adapter.is_none() {
685
+ rt_adapter = Some(cand);
686
+ }
687
+ }
688
+
689
+ let adapter = match rt_adapter {
690
+ Some(a) => a,
691
+ None => pollster_block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
692
+ compatible_surface: Some(&surface),
693
+ power_preference: wgpu::PowerPreference::HighPerformance,
694
+ ..Default::default()
695
+ }))
696
+ .expect("No adapter found"),
697
+ };
698
+
699
+ {
700
+ // One line of boot truth: which GPU we got and whether the
701
+ // features that silently reshape the frame (HW ray query for
702
+ // GI, timestamps for the profiler) are available on it.
703
+ let info = adapter.get_info();
704
+ eprintln!(
705
+ "bloom: adapter '{}' ({:?}), ray_query={}, timestamps={}, tex_arrays={}",
706
+ info.name,
707
+ info.backend,
708
+ adapter.features().contains(wgpu::Features::EXPERIMENTAL_RAY_QUERY),
709
+ adapter.features().contains(wgpu::Features::TIMESTAMP_QUERY),
710
+ adapter.features().contains(
711
+ wgpu::Features::TEXTURE_BINDING_ARRAY
712
+ | wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING
713
+ ),
714
+ );
715
+ }
716
+
717
+ // Ticket 007b: HW ray-query via DXR 1.1 / VK_KHR_ray_query.
718
+ let supported = adapter.features();
719
+ // 2026-07-16: HW ray query is OPT-IN now, not granted-by-default.
720
+ // Measured on the shooter (760M, PT off): merely granting the
721
+ // feature flips SSGI/WSRC to the HW trace and registers every
722
+ // skinned draw for per-frame pre-skin + BLAS builds — costing
723
+ // +20 ms/frame (14.4 vs 20.3 fps) AND losing the sun's cast
724
+ // shadows (screenshot-confirmed twice). Until the HW path is
725
+ // both cheaper and visually correct, nobody should get it by
726
+ // accident just because dxcompiler.dll sits beside the exe.
727
+ // Opt in with BLOOM_HW_GI=1; --pt / BLOOM_PT keep working (the
728
+ // path tracer needs the feature at device creation).
729
+ let want_hw = std::env::var("BLOOM_HW_GI")
730
+ .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
731
+ .unwrap_or(false)
732
+ || std::env::var("BLOOM_PT").is_ok()
733
+ || std::env::args().any(|a| a == "--pt");
734
+ let force_sw_gi = !want_hw
735
+ || std::env::var("BLOOM_FORCE_SW_GI")
736
+ .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
737
+ .unwrap_or(false);
738
+ let rt_mask = wgpu::Features::EXPERIMENTAL_RAY_QUERY;
739
+ eprintln!("bloom: hw-gi opt-in: want_hw={want_hw} force_sw_gi={force_sw_gi}");
740
+ let mut required_features = wgpu::Features::empty();
741
+ // Ticket 011: request TIMESTAMP_QUERY when supported so the profiler
742
+ // can record GPU timings. Optional — profiler falls back to CPU-only
743
+ // when the adapter doesn't grant it.
744
+ if supported.contains(wgpu::Features::TIMESTAMP_QUERY) {
745
+ required_features |= wgpu::Features::TIMESTAMP_QUERY;
746
+ }
747
+ // Cooked BC7 textures (bloom-cook) upload compressed when the
748
+ // adapter has BC support; without it they CPU-decode at load.
749
+ if supported.contains(wgpu::Features::TEXTURE_COMPRESSION_BC) {
750
+ required_features |= wgpu::Features::TEXTURE_COMPRESSION_BC;
751
+ }
752
+ if !force_sw_gi && supported.contains(rt_mask) {
753
+ required_features |= rt_mask;
754
+ }
755
+ // PT-2: texture binding array + non-uniform indexing for textured
756
+ // path-trace hit shading. Both or neither (the kernel indexes the
757
+ // array with a per-thread material id).
758
+ let pt_tex_mask = wgpu::Features::TEXTURE_BINDING_ARRAY
759
+ | wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING;
760
+ if supported.contains(pt_tex_mask) {
761
+ required_features |= pt_tex_mask;
762
+ }
763
+ let experimental_features = if required_features.intersects(rt_mask) {
764
+ unsafe { wgpu::ExperimentalFeatures::enabled() }
765
+ } else {
766
+ wgpu::ExperimentalFeatures::disabled()
767
+ };
768
+ let mut required_limits = wgpu::Limits::default();
769
+ // Phase 1c: the material ABI declares 5 bind groups (PerFrame,
770
+ // PerView, PerMaterial, PerDraw, SceneInputs). wgpu's default
771
+ // limit is 4. Metal / Vulkan / D3D12 support at least 7, so 5 is
772
+ // safely within every real backend's capabilities.
773
+ required_limits.max_bind_groups = 5;
774
+ // The refractive/translucent material profile binds up to 19
775
+ // sampled textures in the fragment stage (5 material maps + env/
776
+ // BRDF/3 shadow cascades/env-diffuse + planar reflection + 3 texture
777
+ // arrays + the group-4 scene_color/scene_depth/impulse/motion inputs).
778
+ // wgpu's default is 16. Raise to whatever the adapter actually
779
+ // supports — every real D3D12/Vulkan/Metal GPU exposes ≥128 — so
780
+ // opaque/transparent materials are unaffected and refractive ones link.
781
+ let adapter_limits = adapter.limits();
782
+ required_limits.max_sampled_textures_per_shader_stage =
783
+ adapter_limits.max_sampled_textures_per_shader_stage;
784
+ required_limits.max_samplers_per_shader_stage =
785
+ adapter_limits.max_samplers_per_shader_stage;
786
+ // PT-2: binding arrays have their own element budget, default 0.
787
+ // Take whatever the adapter offers; the renderer checks the
788
+ // granted value against its fixed array size before compiling
789
+ // the textured kernel variant.
790
+ if required_features.contains(pt_tex_mask) {
791
+ required_limits.max_binding_array_elements_per_shader_stage =
792
+ adapter_limits.max_binding_array_elements_per_shader_stage;
793
+ }
794
+ // PT-4: the path-trace kernel binds 9 storage buffers (accum +
795
+ // moments + reservoir ping-pongs on top of instance/geo data);
796
+ // the wgpu default limit is 8.
797
+ required_limits.max_storage_buffers_per_shader_stage = required_limits
798
+ .max_storage_buffers_per_shader_stage
799
+ .max(adapter_limits.max_storage_buffers_per_shader_stage.min(16));
800
+ if required_features.intersects(rt_mask) {
801
+ required_limits = required_limits
802
+ .using_minimum_supported_acceleration_structure_values();
803
+ }
804
+ let (device, queue) = pollster_block_on(adapter.request_device(
805
+ &wgpu::DeviceDescriptor {
806
+ label: Some("bloom_device"),
807
+ required_features,
808
+ required_limits,
809
+ experimental_features,
810
+ ..Default::default()
811
+ },
812
+ )).expect("Failed to create device");
813
+
814
+ let surface_caps = surface.get_capabilities(&adapter);
815
+ let format = surface_caps.formats[0];
816
+ // Surface is configured at the *physical* client-area size we
817
+ // got back from create_window; Renderer::new takes the
818
+ // caller's logical size separately so screenWidth() etc. keep
819
+ // returning DPI-independent numbers.
820
+ let surface_config = wgpu::SurfaceConfiguration {
821
+ // COPY_SRC: bloom_take_screenshot reads the swapchain back;
822
+ // without it the readback copy is a swallowed validation
823
+ // error and screenshots silently produce nothing on Windows.
824
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT
825
+ | wgpu::TextureUsages::COPY_SRC,
826
+ format,
827
+ width: phys_w,
828
+ height: phys_h,
829
+ present_mode: wgpu::PresentMode::Fifo,
830
+ alpha_mode: surface_caps.alpha_modes[0],
831
+ view_formats: vec![],
832
+ desired_maximum_frame_latency: 2,
833
+ };
834
+ surface.configure(&device, &surface_config);
835
+
836
+ // `Renderer::new` routes initial target creation through `resize`
837
+ // itself, so the construction-time targets already honour
838
+ // render_scale — no explicit resize needed here (it used to live
839
+ // here, which left every non-Windows host with the bug).
840
+ let renderer = Renderer::new(device, queue, surface, surface_config, logical_w, logical_h);
841
+ let _ = ENGINE.set(EngineState::new(renderer));
842
+ }
843
+
844
+ /// Attach the engine to a host-owned `HWND` instead of creating its own
845
+ /// top-level window (PerryTS/perry#5519). `handle` is the child `HWND`
846
+ /// the host (Perry UI's `BloomView`) owns; `width`/`height` are its
847
+ /// client size in physical pixels. Returns 1.0 on success, 0.0 on a
848
+ /// null/invalid handle or surface bring-up failure. Idempotent once
849
+ /// attached.
850
+ #[no_mangle]
851
+ pub extern "C" fn bloom_attach_native(handle: i64, width: f64, height: f64) -> f64 {
852
+ if handle == 0 {
853
+ return 0.0;
854
+ }
855
+ if unsafe { ENGINE.get() }.is_some() {
856
+ return 1.0;
857
+ }
858
+
859
+ #[cfg(windows)]
860
+ {
861
+ let Some(hwnd_nz) = std::num::NonZeroIsize::new(handle as isize) else {
862
+ return 0.0;
863
+ };
864
+ let target = {
865
+ let h = raw_window_handle::Win32WindowHandle::new(hwnd_nz);
866
+ wgpu::SurfaceTargetUnsafe::RawHandle {
867
+ raw_display_handle: Some(raw_window_handle::RawDisplayHandle::Windows(
868
+ raw_window_handle::WindowsDisplayHandle::new(),
869
+ )),
870
+ raw_window_handle: raw_window_handle::RawWindowHandle::Win32(h),
871
+ }
872
+ };
873
+ match unsafe {
874
+ bloom_shared::attach::attach_engine(
875
+ target,
876
+ bloom_shared::attach::AttachParams {
877
+ backends: wgpu::Backends::DX12 | wgpu::Backends::VULKAN,
878
+ logical_w: (width as u32).max(1),
879
+ logical_h: (height as u32).max(1),
880
+ physical_w: (width as u32).max(1),
881
+ physical_h: (height as u32).max(1),
882
+ format: bloom_shared::attach::FormatPreference::First,
883
+ },
884
+ )
885
+ } {
886
+ Ok(es) => {
887
+ unsafe {
888
+ let _ = ENGINE.set(es);
889
+ }
890
+ 1.0
891
+ }
892
+ Err(_) => 0.0,
893
+ }
894
+ }
895
+
896
+ #[cfg(not(windows))]
897
+ {
898
+ let _ = (width, height);
899
+ 0.0
900
+ }
901
+ }
902
+
903
+ /// Request a clean exit: makes `bloom_window_should_close` report true so the
904
+ /// game loop (blocking `while` or `runGame`) falls out on its next check.
905
+ /// Was an empty stub — callable from a game's quit path since SH-054 turned
906
+ /// the shooter's `break`-based exits into closeWindow() calls.
907
+ #[no_mangle]
908
+ pub extern "C" fn bloom_close_window() {
909
+ engine().should_close = true;
910
+ }
911
+
912
+ /// Attach the engine to a host-provided child window (a Perry UI `BloomView`).
913
+ /// `hwnd_bits` is the raw HWND value as an integer (from `bloomViewGetHwnd`).
914
+ /// `width`/`height` are the logical viewport size. The engine builds its wgpu
915
+ /// surface on this window and subclasses it for resize/input; the host drives
916
+ /// frames via `bloom_begin_drawing` / `bloom_end_drawing`.
917
+ #[no_mangle]
918
+ pub extern "C" fn bloom_attach_hwnd(hwnd_bits: f64, width: f64, height: f64) {
919
+ #[cfg(windows)]
920
+ unsafe {
921
+ use windows::Win32::Foundation::{HWND, RECT};
922
+ use windows::Win32::UI::WindowsAndMessaging::GetClientRect;
923
+ let hwnd = HWND(hwnd_bits as i64 as isize as *mut core::ffi::c_void);
924
+ let mut rect = RECT::default();
925
+ let _ = GetClientRect(hwnd, &mut rect);
926
+ let phys_w = (rect.right - rect.left).max(1) as u32;
927
+ let phys_h = (rect.bottom - rect.top).max(1) as u32;
928
+ if ENGINE.get().is_none() {
929
+ init_engine_for_hwnd(hwnd, width as u32, height as u32, phys_w, phys_h);
930
+ }
931
+ EMBEDDED = true;
932
+ win32::attach_subclass(hwnd);
933
+ }
934
+ #[cfg(not(windows))]
935
+ { let _ = (hwnd_bits, width, height); }
936
+ }
937
+
938
+ /// Resize the engine's surface. `phys_*` are physical pixels, `log_*` logical.
939
+ /// Embedded `BloomView`s resize automatically via the subclassed WM_SIZE; this
940
+ /// is exposed for hosts that need to drive the size explicitly.
941
+ #[no_mangle]
942
+ pub extern "C" fn bloom_resize(phys_w: f64, phys_h: f64, log_w: f64, log_h: f64) {
943
+ #[cfg(windows)]
944
+ unsafe {
945
+ if let Some(eng) = ENGINE.get_mut() {
946
+ eng.renderer.resize(phys_w as u32, phys_h as u32, log_w as u32, log_h as u32);
947
+ }
948
+ }
949
+ #[cfg(not(windows))]
950
+ { let _ = (phys_w, phys_h, log_w, log_h); }
951
+ }
952
+
953
+ #[no_mangle]
954
+ pub extern "C" fn bloom_window_should_close() -> f64 {
955
+ #[cfg(windows)]
956
+ unsafe { if EMBEDDED { return 0.0; } }
957
+ if engine().should_close { 1.0 } else { 0.0 }
958
+ }
959
+
960
+ #[cfg(windows)]
961
+ fn poll_xinput_gamepad() {
962
+ use windows::Win32::UI::Input::XboxController::*;
963
+ let eng = engine();
964
+ let mut state = XINPUT_STATE::default();
965
+ let result = unsafe { XInputGetState(0, &mut state) };
966
+ if result == 0 {
967
+ // ERROR_SUCCESS
968
+ eng.input.gamepad_available = true;
969
+ let gp = &state.Gamepad;
970
+
971
+ // Axes: left stick X/Y, right stick X/Y, triggers
972
+ let normalize = |v: i16| -> f32 {
973
+ if v > 0 { v as f32 / 32767.0 } else { v as f32 / 32768.0 }
974
+ };
975
+ eng.input.set_gamepad_axis(0, normalize(gp.sThumbLX));
976
+ eng.input.set_gamepad_axis(1, -normalize(gp.sThumbLY)); // invert Y
977
+ eng.input.set_gamepad_axis(2, normalize(gp.sThumbRX));
978
+ eng.input.set_gamepad_axis(3, -normalize(gp.sThumbRY));
979
+ eng.input.set_gamepad_axis(4, gp.bLeftTrigger as f32 / 255.0);
980
+ eng.input.set_gamepad_axis(5, gp.bRightTrigger as f32 / 255.0);
981
+ eng.input.gamepad_axis_count = 6;
982
+
983
+ // Buttons
984
+ let buttons = gp.wButtons;
985
+ let mappings: &[(u16, usize)] = &[
986
+ (0x1000, 0), // A
987
+ (0x2000, 1), // B
988
+ (0x4000, 2), // X
989
+ (0x8000, 3), // Y
990
+ (0x0100, 4), // Left bumper
991
+ (0x0200, 5), // Right bumper
992
+ (0x0020, 6), // Back/Select
993
+ (0x0010, 7), // Start
994
+ (0x0040, 8), // Left stick press
995
+ (0x0080, 9), // Right stick press
996
+ (0x0001, 10), // DPad Up
997
+ (0x0002, 11), // DPad Down
998
+ (0x0004, 12), // DPad Left
999
+ (0x0008, 13), // DPad Right
1000
+ ];
1001
+ for &(mask, idx) in mappings {
1002
+ if buttons.0 & mask != 0 {
1003
+ eng.input.set_gamepad_button_down(idx);
1004
+ } else {
1005
+ eng.input.set_gamepad_button_up(idx);
1006
+ }
1007
+ }
1008
+
1009
+ // EN-031 — rumble. The FFI writes (low, high, seconds) into shared
1010
+ // input state; we own the motors and the countdown. Only push a new
1011
+ // XInputSetState when the commanded level actually changes, since the
1012
+ // call goes out over USB/BT and doing it every frame is wasteful.
1013
+ let dt = eng.delta_time as f32;
1014
+ let (lo, hi, mut left) = (eng.input.rumble[0], eng.input.rumble[1], eng.input.rumble[2]);
1015
+ let (want_lo, want_hi) = if left > 0.0 { (lo, hi) } else { (0.0, 0.0) };
1016
+ unsafe {
1017
+ if (want_lo, want_hi) != LAST_RUMBLE {
1018
+ let mut vib = XINPUT_VIBRATION {
1019
+ wLeftMotorSpeed: (want_lo * 65535.0) as u16,
1020
+ wRightMotorSpeed: (want_hi * 65535.0) as u16,
1021
+ };
1022
+ let _ = XInputSetState(0, &mut vib);
1023
+ LAST_RUMBLE = (want_lo, want_hi);
1024
+ }
1025
+ }
1026
+ if left > 0.0 {
1027
+ left = (left - dt).max(0.0);
1028
+ eng.input.rumble[2] = left;
1029
+ }
1030
+ } else {
1031
+ eng.input.gamepad_available = false;
1032
+ // Pad unplugged mid-rumble: forget the commanded level so a
1033
+ // reconnecting pad doesn't inherit a stale "still buzzing" state.
1034
+ unsafe { LAST_RUMBLE = (0.0, 0.0); }
1035
+ }
1036
+ }
1037
+
1038
+ /// Last vibration level actually pushed to the pad, so we only call
1039
+ /// XInputSetState on change.
1040
+ #[cfg(windows)]
1041
+ static mut LAST_RUMBLE: (f32, f32) = (0.0, 0.0);
1042
+
1043
+ #[no_mangle]
1044
+ pub extern "C" fn bloom_begin_drawing() {
1045
+ #[cfg(windows)]
1046
+ {
1047
+ // In embedded mode the host (Perry UI) owns the message loop and
1048
+ // dispatches our subclassed window's messages — pumping here would
1049
+ // steal messages from the host. Only poll when Bloom owns the window.
1050
+ unsafe { if !EMBEDDED { win32::poll_events(); } }
1051
+ poll_xinput_gamepad();
1052
+ }
1053
+ engine().begin_frame();
1054
+ }
1055
+
1056
+ #[no_mangle]
1057
+ pub extern "C" fn bloom_end_drawing() { engine().end_frame(); }
1058
+
1059
+ static AUDIO_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1060
+
1061
+ #[no_mangle]
1062
+ pub extern "C" fn bloom_init_audio() {
1063
+ #[cfg(windows)]
1064
+ {
1065
+ use std::sync::atomic::Ordering;
1066
+ AUDIO_RUNNING.store(true, Ordering::SeqCst);
1067
+
1068
+ // Move the render half into the audio thread; the engine keeps
1069
+ // only the command-producing control half.
1070
+ let renderer = engine().audio.take_renderer();
1071
+ std::thread::spawn(move || {
1072
+ unsafe { wasapi_audio_thread(renderer); }
1073
+ });
1074
+ }
1075
+ }
1076
+
1077
+ #[no_mangle]
1078
+ pub extern "C" fn bloom_close_audio() {
1079
+ AUDIO_RUNNING.store(false, std::sync::atomic::Ordering::SeqCst);
1080
+ std::thread::sleep(std::time::Duration::from_millis(50));
1081
+ }
1082
+
1083
+ #[cfg(windows)]
1084
+ unsafe fn wasapi_audio_thread(mut renderer: Option<bloom_shared::audio::AudioRenderer>) {
1085
+ use windows::Win32::Media::Audio::*;
1086
+ use windows::Win32::System::Com::*;
1087
+ use windows::core::*;
1088
+ use std::sync::atomic::Ordering;
1089
+
1090
+ // Initialize COM on this thread
1091
+ let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
1092
+
1093
+ // Create device enumerator and get default output device
1094
+ let enumerator: IMMDeviceEnumerator = match CoCreateInstance(
1095
+ &MMDeviceEnumerator,
1096
+ None,
1097
+ CLSCTX_ALL,
1098
+ ) {
1099
+ Ok(e) => e,
1100
+ Err(_) => return,
1101
+ };
1102
+
1103
+ let device = match enumerator.GetDefaultAudioEndpoint(eRender, eConsole) {
1104
+ Ok(d) => d,
1105
+ Err(_) => return,
1106
+ };
1107
+
1108
+ let audio_client: IAudioClient = match device.Activate(CLSCTX_ALL, None) {
1109
+ Ok(c) => c,
1110
+ Err(_) => return,
1111
+ };
1112
+
1113
+ // Get mix format
1114
+ let mix_format_ptr = match audio_client.GetMixFormat() {
1115
+ Ok(f) => f,
1116
+ Err(_) => return,
1117
+ };
1118
+ let mix_format = &*mix_format_ptr;
1119
+ let sample_rate = mix_format.nSamplesPerSec;
1120
+ let channels = mix_format.nChannels as usize;
1121
+
1122
+ // Initialize in shared mode with 20ms buffer
1123
+ let buffer_duration = 200_000; // 20ms in 100-nanosecond units
1124
+ if audio_client.Initialize(
1125
+ AUDCLNT_SHAREMODE_SHARED,
1126
+ 0,
1127
+ buffer_duration,
1128
+ 0,
1129
+ mix_format_ptr,
1130
+ None,
1131
+ ).is_err() {
1132
+ return;
1133
+ }
1134
+
1135
+ let buffer_size = match audio_client.GetBufferSize() {
1136
+ Ok(s) => s as usize,
1137
+ Err(_) => return,
1138
+ };
1139
+
1140
+ let render_client: IAudioRenderClient = match audio_client.GetService() {
1141
+ Ok(r) => r,
1142
+ Err(_) => return,
1143
+ };
1144
+
1145
+ let _ = audio_client.Start();
1146
+
1147
+ // EN-062 — tell the renderer the DEVICE rate. It defaulted to 44.1 kHz
1148
+ // forever, so on the typical 48 kHz endpoint every filter cutoff and
1149
+ // block-dt was ~9% off (and, pre-resampler, every sound played sharp).
1150
+ if let Some(r) = renderer.as_mut() {
1151
+ r.set_sample_rate(sample_rate as f32);
1152
+ }
1153
+
1154
+ // Temporary buffer for mixing (always stereo f32 from our mixer)
1155
+ let mut mix_buf = vec![0.0f32; buffer_size * 2];
1156
+
1157
+ while AUDIO_RUNNING.load(Ordering::SeqCst) {
1158
+ let padding = audio_client.GetCurrentPadding().unwrap_or(0) as usize;
1159
+ let available = buffer_size - padding;
1160
+ if available == 0 {
1161
+ std::thread::sleep(std::time::Duration::from_millis(2));
1162
+ continue;
1163
+ }
1164
+
1165
+ let buffer_ptr = match render_client.GetBuffer(available as u32) {
1166
+ Ok(p) => p,
1167
+ Err(_) => { std::thread::sleep(std::time::Duration::from_millis(2)); continue; }
1168
+ };
1169
+
1170
+ // Mix audio
1171
+ let mix_samples = available * 2; // stereo
1172
+ for i in 0..mix_samples { mix_buf[i] = 0.0; }
1173
+ // Renderer is moved into this thread at spawn — no shared
1174
+ // engine state is touched from here (see audio/mod.rs contract).
1175
+ if let Some(r) = renderer.as_mut() {
1176
+ r.mix(&mut mix_buf[..mix_samples]);
1177
+ }
1178
+
1179
+ // Write to WASAPI buffer (format is typically f32 or i16, assume float since we requested shared mode)
1180
+ let bits = mix_format.wBitsPerSample;
1181
+ let out_channels = channels;
1182
+ if bits == 32 {
1183
+ let out = std::slice::from_raw_parts_mut(buffer_ptr as *mut f32, available * out_channels);
1184
+ for i in 0..available {
1185
+ let l = mix_buf[i * 2];
1186
+ let r = if i * 2 + 1 < mix_samples { mix_buf[i * 2 + 1] } else { l };
1187
+ if out_channels >= 2 {
1188
+ out[i * out_channels] = l;
1189
+ out[i * out_channels + 1] = r;
1190
+ for c in 2..out_channels { out[i * out_channels + c] = 0.0; }
1191
+ } else {
1192
+ out[i] = (l + r) * 0.5;
1193
+ }
1194
+ }
1195
+ } else if bits == 16 {
1196
+ let out = std::slice::from_raw_parts_mut(buffer_ptr as *mut i16, available * out_channels);
1197
+ for i in 0..available {
1198
+ let l = mix_buf[i * 2];
1199
+ let r = if i * 2 + 1 < mix_samples { mix_buf[i * 2 + 1] } else { l };
1200
+ if out_channels >= 2 {
1201
+ out[i * out_channels] = (l * 32767.0) as i16;
1202
+ out[i * out_channels + 1] = (r * 32767.0) as i16;
1203
+ for c in 2..out_channels { out[i * out_channels + c] = 0; }
1204
+ } else {
1205
+ out[i] = ((l + r) * 0.5 * 32767.0) as i16;
1206
+ }
1207
+ }
1208
+ }
1209
+
1210
+ let _ = render_client.ReleaseBuffer(available as u32, 0);
1211
+ std::thread::sleep(std::time::Duration::from_millis(2));
1212
+ }
1213
+
1214
+ let _ = audio_client.Stop();
1215
+ CoUninitialize();
1216
+ }
1217
+
1218
+ // --- Texture FFI ---
1219
+
1220
+ // --- Camera FFI ---
1221
+
1222
+ // --- 3D Drawing FFI ---
1223
+
1224
+ // --- Model FFI ---
1225
+
1226
+ // ============================================================
1227
+ // Phase 1c — material system FFI
1228
+ // ============================================================
1229
+
1230
+ // --- Music FFI ---
1231
+
1232
+ // --- Gamepad FFI ---
1233
+
1234
+ // --- Skeletal Animation Debug ---
1235
+
1236
+ // --- Lighting ---
1237
+
1238
+ // --- Utility FFI ---
1239
+
1240
+ #[no_mangle]
1241
+ pub extern "C" fn bloom_toggle_fullscreen() {
1242
+ #[cfg(windows)]
1243
+ win32::toggle_fullscreen();
1244
+ }
1245
+ #[no_mangle]
1246
+ pub extern "C" fn bloom_set_window_title(title_ptr: *const u8) {
1247
+ // Real since 2026-07-17 — the stub read the string and discarded it.
1248
+ use windows::Win32::UI::WindowsAndMessaging::SetWindowTextW;
1249
+ use windows::core::PCWSTR;
1250
+ let title = str_from_header(title_ptr);
1251
+ if let Some(hwnd) = win32::main_hwnd() {
1252
+ let wide: Vec<u16> = title.encode_utf16().chain(std::iter::once(0)).collect();
1253
+ unsafe { let _ = SetWindowTextW(hwnd, PCWSTR(wide.as_ptr())); }
1254
+ }
1255
+ }
1256
+ #[no_mangle]
1257
+ pub extern "C" fn bloom_set_window_icon(path_ptr: *const u8) { let _ = str_from_header(path_ptr); }
1258
+
1259
+ #[no_mangle]
1260
+ pub extern "C" fn bloom_disable_cursor() {
1261
+ engine().input.cursor_disabled = true;
1262
+ // Hide the OS cursor (ShowCursor keeps a counter; loop until it's hidden).
1263
+ unsafe { while windows::Win32::UI::WindowsAndMessaging::ShowCursor(false) >= 0 {} }
1264
+ }
1265
+
1266
+ #[no_mangle]
1267
+ pub extern "C" fn bloom_enable_cursor() {
1268
+ engine().input.cursor_disabled = false;
1269
+ unsafe { while windows::Win32::UI::WindowsAndMessaging::ShowCursor(true) < 0 {} }
1270
+ }
1271
+
1272
+ // E4: Clipboard — real Win32 implementation (was a stub until 2026-07-17,
1273
+ // so paste in the editor's text fields silently never worked on Windows).
1274
+ #[no_mangle]
1275
+ pub extern "C" fn bloom_set_clipboard_text(text_ptr: *const u8) {
1276
+ use windows::Win32::System::DataExchange::{
1277
+ OpenClipboard, CloseClipboard, EmptyClipboard, SetClipboardData,
1278
+ };
1279
+ use windows::Win32::System::Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GMEM_MOVEABLE};
1280
+ use windows::Win32::System::Ole::CF_UNICODETEXT;
1281
+ use windows::Win32::Foundation::{HANDLE, HWND};
1282
+
1283
+ let text = str_from_header(text_ptr);
1284
+ unsafe {
1285
+ let owner = win32::main_hwnd().unwrap_or(HWND(std::ptr::null_mut()));
1286
+ if OpenClipboard(owner).is_err() {
1287
+ return;
1288
+ }
1289
+ let _ = EmptyClipboard();
1290
+ let wide: Vec<u16> = text.encode_utf16().chain(std::iter::once(0)).collect();
1291
+ if let Ok(hmem) = GlobalAlloc(GMEM_MOVEABLE, wide.len() * 2) {
1292
+ let dst = GlobalLock(hmem) as *mut u16;
1293
+ if !dst.is_null() {
1294
+ std::ptr::copy_nonoverlapping(wide.as_ptr(), dst, wide.len());
1295
+ let _ = GlobalUnlock(hmem);
1296
+ // The system owns the memory on success; on failure we leak a
1297
+ // small block rather than double-free.
1298
+ let _ = SetClipboardData(CF_UNICODETEXT.0 as u32, HANDLE(hmem.0));
1299
+ }
1300
+ }
1301
+ let _ = CloseClipboard();
1302
+ }
1303
+ }
1304
+
1305
+ #[no_mangle]
1306
+ pub extern "C" fn bloom_get_clipboard_text() -> *const u8 {
1307
+ use windows::Win32::System::DataExchange::{OpenClipboard, CloseClipboard, GetClipboardData};
1308
+ use windows::Win32::System::Memory::{GlobalLock, GlobalUnlock};
1309
+ use windows::Win32::System::Ole::CF_UNICODETEXT;
1310
+ use windows::Win32::Foundation::{HGLOBAL, HWND};
1311
+
1312
+ unsafe {
1313
+ let owner = win32::main_hwnd().unwrap_or(HWND(std::ptr::null_mut()));
1314
+ if OpenClipboard(owner).is_err() {
1315
+ return alloc_perry_string("");
1316
+ }
1317
+ let mut out = String::new();
1318
+ if let Ok(handle) = GetClipboardData(CF_UNICODETEXT.0 as u32) {
1319
+ let hglobal = HGLOBAL(handle.0);
1320
+ let ptr = GlobalLock(hglobal) as *const u16;
1321
+ if !ptr.is_null() {
1322
+ let mut len = 0usize;
1323
+ while *ptr.add(len) != 0 {
1324
+ len += 1;
1325
+ }
1326
+ out = String::from_utf16_lossy(std::slice::from_raw_parts(ptr, len));
1327
+ let _ = GlobalUnlock(hglobal);
1328
+ }
1329
+ }
1330
+ let _ = CloseClipboard();
1331
+ alloc_perry_string(&out)
1332
+ }
1333
+ }
1334
+
1335
+ // E5b: File dialogs — real Win32 implementation (stubs until 2026-07-17: the
1336
+ // editor's Open/Save buttons silently did nothing on Windows). The filter
1337
+ // argument is a simple pattern like "*.world.json"; empty means all files.
1338
+ // OFN_NOCHANGEDIR is load-bearing: the common dialogs change the process CWD
1339
+ // by default, which would break every relative asset path afterward.
1340
+ #[cfg(windows)]
1341
+ fn run_file_dialog(filter: &str, title: &str, save: bool, default_name: &str) -> String {
1342
+ use windows::Win32::UI::Controls::Dialogs::{
1343
+ GetOpenFileNameW, GetSaveFileNameW, OPENFILENAMEW,
1344
+ OFN_FILEMUSTEXIST, OFN_PATHMUSTEXIST, OFN_NOCHANGEDIR, OFN_OVERWRITEPROMPT,
1345
+ };
1346
+ use windows::core::{PCWSTR, PWSTR};
1347
+
1348
+ // Filter block: "Matching files\0<pattern>\0All files\0*.*\0\0".
1349
+ let pattern = if filter.is_empty() { "*.*" } else { filter };
1350
+ let mut filter_w: Vec<u16> = Vec::new();
1351
+ filter_w.extend("Matching files".encode_utf16());
1352
+ filter_w.push(0);
1353
+ filter_w.extend(pattern.encode_utf16());
1354
+ filter_w.push(0);
1355
+ filter_w.extend("All files".encode_utf16());
1356
+ filter_w.push(0);
1357
+ filter_w.extend("*.*".encode_utf16());
1358
+ filter_w.push(0);
1359
+ filter_w.push(0);
1360
+
1361
+ let title_w: Vec<u16> = title.encode_utf16().chain(std::iter::once(0)).collect();
1362
+
1363
+ let mut file_buf = [0u16; 4096];
1364
+ if save && !default_name.is_empty() {
1365
+ for (i, c) in default_name.encode_utf16().take(4000).enumerate() {
1366
+ file_buf[i] = c;
1367
+ }
1368
+ }
1369
+
1370
+ let mut ofn = OPENFILENAMEW::default();
1371
+ ofn.lStructSize = std::mem::size_of::<OPENFILENAMEW>() as u32;
1372
+ ofn.hwndOwner = win32::main_hwnd().unwrap_or_default();
1373
+ ofn.lpstrFilter = PCWSTR(filter_w.as_ptr());
1374
+ ofn.lpstrFile = PWSTR(file_buf.as_mut_ptr());
1375
+ ofn.nMaxFile = file_buf.len() as u32;
1376
+ if !title.is_empty() {
1377
+ ofn.lpstrTitle = PCWSTR(title_w.as_ptr());
1378
+ }
1379
+ ofn.Flags = if save {
1380
+ OFN_OVERWRITEPROMPT | OFN_NOCHANGEDIR
1381
+ } else {
1382
+ OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR
1383
+ };
1384
+
1385
+ let ok = unsafe {
1386
+ if save { GetSaveFileNameW(&mut ofn) } else { GetOpenFileNameW(&mut ofn) }
1387
+ };
1388
+ if !ok.as_bool() {
1389
+ return String::new();
1390
+ }
1391
+ let len = file_buf.iter().position(|&c| c == 0).unwrap_or(0);
1392
+ String::from_utf16_lossy(&file_buf[..len])
1393
+ }
1394
+
1395
+ #[no_mangle]
1396
+ pub extern "C" fn bloom_open_file_dialog(filter_ptr: *const u8, title_ptr: *const u8) -> *const u8 {
1397
+ let filter = str_from_header(filter_ptr);
1398
+ let title = str_from_header(title_ptr);
1399
+ let path = run_file_dialog(&filter, &title, false, "");
1400
+ alloc_perry_string(&path)
1401
+ }
1402
+
1403
+ #[no_mangle]
1404
+ pub extern "C" fn bloom_save_file_dialog(default_name_ptr: *const u8, title_ptr: *const u8) -> *const u8 {
1405
+ let default_name = str_from_header(default_name_ptr);
1406
+ let title = str_from_header(title_ptr);
1407
+ let path = run_file_dialog("", &title, true, &default_name);
1408
+ alloc_perry_string(&path)
1409
+ }
1410
+ #[no_mangle]
1411
+ pub extern "C" fn bloom_get_platform() -> f64 { 3.0 }
1412
+
1413
+ /// Preferred OS language packed as `c0*256+c1` (ISO-639 primary subtag), from
1414
+ /// `GetUserDefaultLocaleName` (e.g. "en-US" -> "en"). Falls back to "en".
1415
+ #[no_mangle]
1416
+ pub extern "C" fn bloom_get_language() -> f64 {
1417
+ use windows::Win32::Globalization::GetUserDefaultLocaleName;
1418
+ let mut buf = [0u16; 85]; // LOCALE_NAME_MAX_LENGTH
1419
+ let n = unsafe { GetUserDefaultLocaleName(&mut buf) };
1420
+ if n >= 2 {
1421
+ let lc = |c: u16| -> u8 { let b = c as u8; if b.is_ascii_uppercase() { b + 32 } else { b } };
1422
+ let (c0, c1) = (lc(buf[0]), lc(buf[1]));
1423
+ if c0.is_ascii_alphabetic() && c1.is_ascii_alphabetic() {
1424
+ return (c0 as f64) * 256.0 + (c1 as f64);
1425
+ }
1426
+ }
1427
+ 25966.0
1428
+ }
1429
+
1430
+ // ============================================================
1431
+ // Thread-safe staging (for async asset loading via Perry threads)
1432
+ // ============================================================
1433
+
1434
+ fn pollster_block_on<F: std::future::Future>(future: F) -> F::Output {
1435
+ use std::task::{Context, Poll, Wake, Waker};
1436
+ use std::pin::Pin;
1437
+ use std::sync::Arc;
1438
+ struct NoopWaker;
1439
+ impl Wake for NoopWaker { fn wake(self: Arc<Self>) {} }
1440
+ let waker = Waker::from(Arc::new(NoopWaker));
1441
+ let mut cx = Context::from_waker(&waker);
1442
+ let mut future = unsafe { Pin::new_unchecked(Box::new(future)) };
1443
+ loop {
1444
+ match future.as_mut().poll(&mut cx) {
1445
+ Poll::Ready(result) => return result,
1446
+ Poll::Pending => std::thread::yield_now(),
1447
+ }
1448
+ }
1449
+ }
1450
+
1451
+
1452
+ // Q6: Multi-hit picking
1453
+ // ============================================================
1454
+
1455
+ // ============================================================
1456
+ // Render quality toggles (individual + preset) — ticket 011
1457
+ // Mirror of the macOS FFI surface added in commit 95da6af; previously
1458
+ // macOS-only, now exposed on every native platform so non-macOS builds
1459
+ // don't fail at runtime (missing symbol) when the TS API invokes them.
1460
+ // ============================================================
1461
+
1462
+ // ============================================================
1463
+ // Render scale / upscale / DRS / post-FX / screenshots / impulse
1464
+ // Ports of the macOS / Linux FFI surface so the bloom/core TS layer
1465
+ // links cleanly on Windows. EngineState in bloom-shared already
1466
+ // exposes the underlying renderer methods, so these wrappers are
1467
+ // platform-agnostic.
1468
+ // ============================================================
1469
+
1470
+ // ============================================================
1471
+ // Profiler — CPU phase timings (always available) + GPU timestamps
1472
+ // (when the adapter supports TIMESTAMP_QUERY). Disabled by default.
1473
+ // ============================================================
1474
+
1475
+ // ============================================================
1476
+ // Physics (Jolt 5.x) — FFI surface generated from shared macro
1477
+ // ============================================================
1478
+
1479
+ #[cfg(feature = "jolt")]
1480
+ #[inline]
1481
+ fn bloom_jolt_ffi_physics() -> &'static mut bloom_shared::physics_jolt::JoltPhysics {
1482
+ &mut engine().jolt
1483
+ }
1484
+
1485
+ #[cfg(feature = "jolt")]
1486
+ bloom_shared::define_physics_ffi!();