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). 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(); 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/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), 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..8246851 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": [], @@ -3634,6 +3646,9 @@ "AudioToolbox", "AVFoundation", "GameController" + ], + "libs": [ + "c++" ] }, "tvos": { 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,