From 94e00fa6006332a91f5ae1d28bed65aa2cc0c8bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 11 Jul 2026 13:39:44 +0200 Subject: [PATCH 1/5] fix(ios): resolve the asset path in compile_material_from_file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every asset-loading FFI routes its path through the platform's resolve_path() hook, which on iOS prepends the app bundle's resource path — relative paths do not resolve against the working directory there, because the CWD is not the bundle. compile_material_from_file was the one loader that didn't, passing the raw relative path straight to the filesystem. That works on the desktop hosts, where the CWD happens to be the asset root, and fails on device. The symptom is a scene that renders with every from-file material silently dropped — in the shooter that meant no water at all, and terrain/glass/muzzle-flash falling back to a default — with nothing but a `canonicalize "assets/materials/water.wgsl": No such file` line on the console to explain it. FFIs that *write* a file (take_screenshot, dump_shadow_map) keep the raw path deliberately: the bundle is read-only, and their output belongs outside it. --- native/shared/src/ffi_core/models.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/native/shared/src/ffi_core/models.rs b/native/shared/src/ffi_core/models.rs index 4b1923f..9895127 100644 --- a/native/shared/src/ffi_core/models.rs +++ b/native/shared/src/ffi_core/models.rs @@ -667,6 +667,13 @@ macro_rules! __bloom_ffi_models { $crate::ffi::guard("bloom_compile_material_from_file", move || { use $crate::renderer::material_pipeline::{FragmentProfile, Bucket}; let path = $crate::string_header::str_from_header(path_ptr); + // Every other asset loader routes through the platform's + // resolve hook; this one read the raw relative path straight + // off the working directory, which is only the asset root on + // the desktop hosts. On iOS the CWD is not the app bundle, so + // every from-file material failed to canonicalize and the whole + // scene lost its shaders. + let path: &str = &bloom_resolve_asset_path(path); let (profile, bucket, reads_scene) = match bucket_kind as u32 { 0 => (FragmentProfile::Opaque, Bucket::Opaque, false), 1 => (FragmentProfile::Translucent, Bucket::Transparent, false), From 1e1f8d6e5df95cfbbb76b4319615c92772462bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 11 Jul 2026 13:39:53 +0200 Subject: [PATCH 2/5] feat(input): add isTouchActive / getMaxTouchPoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Touch points are addressed by slot, but getTouchCount() returns the number of *live* fingers — and the two spaces diverge the moment a finger lifts out of order. Hold two fingers, lift the first, and the live finger sits at slot 1 while the count reads 1. The obvious loop, `for (i = 0; i < getTouchCount(); i++)`, therefore reads slot 0 — released, but still carrying the coordinates it had when it left the glass — as though it were a live touch. It presents as a finger frozen in place: a virtual stick that stays deflected and keeps walking the player after the thumb is long gone. src/mobile's VirtualJoystick has this shape today, and games that hand-roll their touch input (jump, garden) inherited it. There was no way to tell from TypeScript which slots were live — getTouchPointCount() just aliases getTouchCount(). Expose the two primitives that make a correct scan possible: for (let i = 0; i < getMaxTouchPoints(); i++) { if (!isTouchActive(i)) continue; ... } Added to the shared define_core_ffi! macro, so every platform on it picks them up; watchOS and web hand-roll their FFI and get explicit implementations. FFI parity is green on all seven. --- native/shared/src/ffi_core/input.rs | 16 ++++++++++++++++ native/shared/src/input.rs | 13 +++++++++++++ native/watchos/src/lib.rs | 12 ++++++++++++ native/web/src/input_ffi.rs | 10 ++++++++++ package.json | 12 ++++++++++++ src/core/index.ts | 21 +++++++++++++++++++++ src/index.ts | 1 + 7 files changed, 85 insertions(+) diff --git a/native/shared/src/ffi_core/input.rs b/native/shared/src/ffi_core/input.rs index 8129fc9..1259adb 100644 --- a/native/shared/src/ffi_core/input.rs +++ b/native/shared/src/ffi_core/input.rs @@ -186,5 +186,21 @@ macro_rules! __bloom_ffi_input { }) } + // bloom_is_touch_active [source: macos] + #[no_mangle] + pub extern "C" fn bloom_is_touch_active(index: f64) -> f64 { + $crate::ffi::guard("bloom_is_touch_active", move || { + if engine().input.is_touch_active(index as usize) { 1.0 } else { 0.0 } + }) + } + + // bloom_get_max_touch_points [source: macos] + #[no_mangle] + pub extern "C" fn bloom_get_max_touch_points() -> f64 { + $crate::ffi::guard("bloom_get_max_touch_points", move || { + engine().input.max_touch_points() as f64 + }) + } + }; } diff --git a/native/shared/src/input.rs b/native/shared/src/input.rs index dc44721..7a8d906 100644 --- a/native/shared/src/input.rs +++ b/native/shared/src/input.rs @@ -254,6 +254,19 @@ impl InputState { if index < MAX_TOUCH_POINTS { self.touch_points[index].y } else { 0.0 } } pub fn get_touch_count(&self) -> usize { self.touch_count } + /// Whether touch *slot* `index` currently holds a live finger. + /// + /// `get_touch_count` counts active fingers, but `touch_points` is indexed + /// by slot, and slots go sparse as soon as a finger lifts out of order + /// (lift slot 0 while slot 1 is still held → count == 1, live finger at + /// slot 1). Scanning `0..get_touch_count()` therefore reads a released + /// slot's stale coordinates as if they were a live touch. Games that track + /// per-finger state must iterate `0..MAX_TOUCH_POINTS` and skip slots this + /// returns false for. + pub fn is_touch_active(&self, index: usize) -> bool { + index < MAX_TOUCH_POINTS && self.touch_points[index].active + } + pub fn max_touch_points(&self) -> usize { MAX_TOUCH_POINTS } // Digital Crown (watchOS) pub fn accumulate_crown_rotation(&mut self, delta: f64) { self.crown_rotation += delta; } diff --git a/native/watchos/src/lib.rs b/native/watchos/src/lib.rs index c1991d7..6e62ff3 100644 --- a/native/watchos/src/lib.rs +++ b/native/watchos/src/lib.rs @@ -306,6 +306,18 @@ pub extern "C" fn bloom_get_touch_count() -> f64 { n } +#[no_mangle] +pub extern "C" fn bloom_is_touch_active(index: f64) -> f64 { + let s = state(); + let i = index as usize; + if i < MAX_TOUCH && s.touch_active[i].load(Ordering::Acquire) != 0 { 1.0 } else { 0.0 } +} + +#[no_mangle] +pub extern "C" fn bloom_get_max_touch_points() -> f64 { + MAX_TOUCH as f64 +} + #[no_mangle] pub extern "C" fn bloom_get_delta_time() -> f64 { let s = state(); diff --git a/native/web/src/input_ffi.rs b/native/web/src/input_ffi.rs index c420f92..6c105a7 100644 --- a/native/web/src/input_ffi.rs +++ b/native/web/src/input_ffi.rs @@ -161,6 +161,16 @@ pub fn bloom_get_touch_count() -> f64 { engine().input.get_touch_count() as f64 } +#[wasm_bindgen] +pub fn bloom_is_touch_active(index: f64) -> f64 { + if engine().input.is_touch_active(index as usize) { 1.0 } else { 0.0 } +} + +#[wasm_bindgen] +pub fn bloom_get_max_touch_points() -> f64 { + engine().input.max_touch_points() as f64 +} + // ============================================================ // Input injection (called from JS event listeners) // ============================================================ diff --git a/package.json b/package.json index 9eb2749..4e70da5 100644 --- a/package.json +++ b/package.json @@ -1830,6 +1830,18 @@ "params": [], "returns": "f64" }, + { + "name": "bloom_is_touch_active", + "params": [ + "f64" + ], + "returns": "f64" + }, + { + "name": "bloom_get_max_touch_points", + "params": [], + "returns": "f64" + }, { "name": "bloom_get_time", "params": [], diff --git a/src/core/index.ts b/src/core/index.ts index 673c8c6..0eaf4ca 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -114,6 +114,8 @@ declare function bloom_get_gamepad_axis_count(): number; declare function bloom_get_touch_x(index: number): number; declare function bloom_get_touch_y(index: number): number; declare function bloom_get_touch_count(): number; +declare function bloom_is_touch_active(index: number): number; +declare function bloom_get_max_touch_points(): number; // Input injection FFI declare function bloom_inject_key_down(key: number): void; @@ -839,6 +841,25 @@ export function getTouchPointCount(): number { return bloom_get_touch_count(); } +/// Is touch *slot* `index` holding a live finger? +/// +/// `getTouchCount()` is the number of active fingers, but touch points are +/// addressed by slot, and slots go sparse the moment a finger lifts out of +/// order: hold two fingers, lift the first, and the live finger is at slot 1 +/// while the count is 1. Iterating `0..getTouchCount()` then reads slot 0 — +/// released, but still carrying its last coordinates — as though it were live, +/// which reads as a finger frozen at the point where it left the glass. +/// +/// Track fingers by scanning `0..getMaxTouchPoints()` and skipping the slots +/// this returns false for. +export function isTouchActive(index: number): boolean { + return bloom_is_touch_active(index) > 0.5; +} + +export function getMaxTouchPoints(): number { + return bloom_get_max_touch_points(); +} + // Utility export function toggleFullscreen(): void { diff --git a/src/index.ts b/src/index.ts index fa475be..826cc6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export { isGamepadAvailable, getGamepadAxis, getGamepadAxisValue, isGamepadButtonPressed, isGamepadButtonDown, isGamepadButtonReleased, getGamepadAxisCount, getTouchX, getTouchY, getTouchCount, getTouchPointCount, + isTouchActive, getMaxTouchPoints, toggleFullscreen, setWindowTitle, setWindowIcon, disableCursor, enableCursor, getMouseDeltaX, getMouseDeltaY, getMouseWheel, getCharPressed, setCursorShape, CursorShape, From 100f523777a938395ef2faa1f00df53158029296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 11 Jul 2026 13:44:34 +0200 Subject: [PATCH 3/5] fix(ios): declare the c++ runtime in the native-library manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jolt is C++, and bloom-ios is a staticlib — so Perry, not cargo, performs the final link, and it takes the libraries to link from `libs` in the manifest. macOS, tvOS and visionOS all declare `c++` there for exactly this reason; iOS was the one Apple target that didn't, leaving any physics-using game to fail the link on undefined std::__1 symbols. --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index 4e70da5..8246851 100644 --- a/package.json +++ b/package.json @@ -3646,6 +3646,9 @@ "AudioToolbox", "AVFoundation", "GameController" + ], + "libs": [ + "c++" ] }, "tvos": { From 50f73d8e035cdc8f74c08a71407630787fcb08c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 11 Jul 2026 13:44:34 +0200 Subject: [PATCH 4/5] fix(render): request the adapter's sampled-texture limits, not wgpu's defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user material's fragment stage binds 19 sampled textures once the scene/GI inputs sit alongside the material's own maps. wgpu's default max_sampled_textures_per_shader_stage is 16. Device creation asked for Limits::default() and only fell back to the adapter's real limits when request_device *failed*. On macOS it doesn't fail — the device is created happily at 16 — so the shortfall surfaced later and further away, as an abort inside create_pipeline_layout('user_material'): Too many bindings of type SampledTextures in Stage FRAGMENT, limit is 16, count was 19 which took the shooter down on every macOS launch, before a frame. iOS was only ever passing by luck: its first request_device fails on an unrelated limit (max_inter_stage_shader_variables, 15 vs a requested 16), so it always retried with adapter.limits() and picked up the texture headroom as a side effect. Ask the adapter for what it actually offers on the limits the material system depends on, never dropping below wgpu's defaults. --- native/shared/src/attach.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/native/shared/src/attach.rs b/native/shared/src/attach.rs index 6aead37..f34d8cf 100644 --- a/native/shared/src/attach.rs +++ b/native/shared/src/attach.rs @@ -137,8 +137,31 @@ pub unsafe fn attach_engine( // The material ABI declares 5 bind groups; wgpu defaults to 4. Every // real backend supports >= 7. + let adapter_limits = adapter.limits(); let mut required_limits = wgpu::Limits::default(); required_limits.max_bind_groups = 5; + + // A user material's fragment stage binds more sampled textures than + // wgpu's default permits — 19 against a default cap of 16, once the + // scene/GI inputs sit alongside the material's own maps. + // + // Only the fallback below ever picked up the adapter's real limits, and + // it runs solely when request_device *fails*. On macOS the default + // request succeeds at 16, so the shortfall surfaced much later, as an + // abort inside create_pipeline_layout('user_material') — the shooter + // could not open on macOS at all. (iOS escaped it by luck: its first + // request fails on an unrelated limit, so it always retried with the + // adapter's limits and got the headroom as a side effect.) + // + // Ask for what the adapter actually offers on the limits the material + // system leans on, never dropping below wgpu's defaults. + required_limits.max_sampled_textures_per_shader_stage = required_limits + .max_sampled_textures_per_shader_stage + .max(adapter_limits.max_sampled_textures_per_shader_stage); + required_limits.max_samplers_per_shader_stage = required_limits + .max_samplers_per_shader_stage + .max(adapter_limits.max_samplers_per_shader_stage); + if required_features.intersects(rt_mask) { required_limits = required_limits.using_minimum_supported_acceleration_structure_values(); From 7a9b0c5e0f1a3ee78fdf1ec959614ede762a5125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 11 Jul 2026 13:44:34 +0200 Subject: [PATCH 5/5] docs: add docs/ios-target.md iOS is the engine's only App Store platform and had no target doc, while watchOS and web both do. The only build instruction anywhere in the repo was the text of a panic! string in native/ios/src/lib.rs. Covers the ios-game-loop requirement and the UIApplicationMain handshake, asset bundling and why every read path must go through resolve_path(), the touch-slot contract, and the known gaps (no CI build, no gamepad, EN-024). --- docs/ios-target.md | 111 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/ios-target.md diff --git a/docs/ios-target.md b/docs/ios-target.md new file mode 100644 index 0000000..6187736 --- /dev/null +++ b/docs/ios-target.md @@ -0,0 +1,111 @@ +# iOS target + +Metal via wgpu, UIKit for the window and touch, CoreAudio for sound, Jolt for +physics. Shipped: [Bloom Jump](https://apps.apple.com/us/app/bloom-jump/id6761447092) +is a Bloom game on the App Store. + +The engine ships a **static library, not an app**. There is no Xcode project +here — Perry owns the app shell (`Info.plist`, `UIApplicationMain`, the bundle, +signing). `native/ios/` is `libbloom_ios.a` and nothing else. + +## Building a game + +```bash +rustup target add aarch64-apple-ios aarch64-apple-ios-sim + +perry setup ios # once: App Store Connect API key + team id +perry setup ios --development # once per device: registers the UDID, mints a profile + +perry compile src/main.ts -o build/Game --target ios --features ios-game-loop +xcrun devicectl device install app --device build/Game.app +xcrun devicectl device process launch --console --device +``` + +`--features ios-game-loop` is **mandatory** for a game and is the single most +common way to get a black screen. UIKit requires `UIApplicationMain()` to own +the main thread forever, but a Bloom game loop (`while (!windowShouldClose())`) +wants it too. The feature makes Perry emit `_perry_user_main` — run on a spawned +game thread — and hands the main thread to `UIApplicationMain`. Without it you +get a plain `main()`, the app links, and no window ever appears. + +The handshake between the two, both implemented in `native/ios/src/lib.rs`: + +- `perry_register_native_classes()` — registers `BloomMetalView` (a `UIView` + whose `+layerClass` is `CAMetalLayer`) before `UIApplicationMain` starts. +- `perry_scene_will_connect()` — creates the `UIWindow`, the view, the wgpu + surface and the engine, on the main thread, from `didFinishLaunching`. +- `bloom_init_window()` — runs on the *game* thread and waits for the engine + the scene delegate is building. It panics after 10s if that never happened, + which in practice means `ios-game-loop` was omitted. + +Perry omits `UIApplicationSceneManifest` from the plist in game-loop mode; older +build notes told you to strip it by hand afterwards, and that is no longer +needed. + +## Assets + +Perry copies `assets/` (and `logo/`, `resources/`, `images/`) from the project +root — located by walking up to the nearest `package.json` — into the `.app`. + +Relative paths do **not** resolve against the working directory on iOS: the CWD +is not the app bundle. Every asset-loading FFI therefore routes its path through +the platform's `resolve_path()` hook, which prepends +`NSBundle.mainBundle.resourcePath`. If you add a new FFI that opens a file for +*reading*, it must resolve too: + +```rust +let path = $crate::string_header::str_from_header(path_ptr); +let path: &str = &bloom_resolve_asset_path(path); +``` + +Skipping that is silent on desktop and fails only on device. `loadMaterial` / +`compileMaterialFromFile` was missing it until 2026-07, and the symptom was a +scene that rendered with every from-file material dropped — water invisible, +terrain on a fallback shader — with only a `canonicalize … No such file` line on +the console to say so. FFIs that *write* a file (`takeScreenshot`, +`dumpShadowMap`) deliberately do not resolve — the bundle is read-only. + +## Input + +Touch is real UIKit multitouch: up to 10 points, `setMultipleTouchEnabled: YES`, +coordinates scaled from points to pixels so they share a space with +`getScreenWidth()` / `getScreenHeight()`. + +Touch 0 is also synthesised into mouse button 0, so mouse-driven games work +unmodified. That is a trap for anything multi-touch: `isMouseButtonDown(0)` is +true whenever *any* first finger is down, so an FPS that reads it as "fire" will +shoot while you are steering with the movement stick. Read the touch API +directly instead. + +**Touch slots are not a dense list.** `getTouchCount()` is the number of live +fingers, but touch points are addressed by *slot*, and slots go sparse the moment +a finger lifts out of order — hold two fingers, lift the first, and the live +finger is at slot 1 while the count is 1. Iterating `0..getTouchCount()` then +reads slot 0 — released, but still holding its last coordinates — as if it were +live, which presents as a finger frozen where it left the glass. Scan +`0..getMaxTouchPoints()` and skip slots that `isTouchActive(i)` rejects. + +Gamepad is **not** implemented on iOS (`GCController` is never polled), despite +the framework being linked. `isGamepadAvailable()` returns false. tvOS has the +code to copy if this is ever needed. + +## Renderer notes + +- Backend is pinned to `wgpu::Backends::METAL`; present mode is `Fifo`. +- Device creation retries: some real devices (iPhone 16 Pro / A18) advertise + `EXPERIMENTAL_RAY_QUERY` on the adapter and then reject it at device creation, + which would abort on launch. The fallback re-requests with `adapter.limits()`, + because iOS GPUs also cap individual limits (e.g. + `max_inter_stage_shader_variables` at 15, under wgpu's default 16) below the + desktop defaults. +- Atmosphere LUTs are tiered down on iOS (`renderer/atmosphere_lut.rs`). +- Deployment target is pinned to iOS 17.0 by Perry. + +## Known gaps + +- **No CI build.** No workflow compiles `native/ios/`; the only iOS gate is + `tools/validate-ffi.js`, which parses `lib.rs` for symbol names and proves + nothing about whether the crate compiles or runs. +- **EN-024** — iOS reports pixels where macOS reports points, so `getScreenWidth()` + and 2D HUD coordinates do not carry across Apple targets. Games currently + compensate themselves (scale the 2D pass through a `beginMode2D` zoom).