Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions docs/ios-target.md
Original file line number Diff line number Diff line change
@@ -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 <UDID> build/Game.app
xcrun devicectl device process launch --console --device <UDID> <bundle-id>
```

`--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).
23 changes: 23 additions & 0 deletions native/shared/src/attach.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
16 changes: 16 additions & 0 deletions native/shared/src/ffi_core/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}

};
}
7 changes: 7 additions & 0 deletions native/shared/src/ffi_core/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
13 changes: 13 additions & 0 deletions native/shared/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
12 changes: 12 additions & 0 deletions native/watchos/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 10 additions & 0 deletions native/web/src/input_ffi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// ============================================================
Expand Down
15 changes: 15 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down Expand Up @@ -3634,6 +3646,9 @@
"AudioToolbox",
"AVFoundation",
"GameController"
],
"libs": [
"c++"
]
},
"tvos": {
Expand Down
21 changes: 21 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading