diff --git a/native/android/src/lib.rs b/native/android/src/lib.rs index 85c4bedd..bd99fac8 100644 --- a/native/android/src/lib.rs +++ b/native/android/src/lib.rs @@ -3,14 +3,82 @@ use bloom_shared::engine::EngineState; use bloom_shared::renderer::Renderer; use bloom_shared::string_header::{alloc_perry_string, str_from_header}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering}; +use std::sync::{Condvar, Mutex, OnceLock}; static mut ENGINE: OnceLock = OnceLock::new(); static mut NATIVE_WINDOW: *mut libc::c_void = std::ptr::null_mut(); static AUDIO_RUNNING: AtomicBool = AtomicBool::new(false); +/// The app is in the background: the stream is paused and nothing is mixed, so +/// music picks up where it was when the app comes back. +static AUDIO_PAUSED: AtomicBool = AtomicBool::new(false); static mut ASSET_BASE_PATH: Option = None; +/// Device pixels, as on iOS: set once the Activity reports its display metrics +/// (`nativeSetDisplayMetrics`). The screen size the game sees is then the +/// surface's pixel size and `getDisplayScale()` is the display density. +/// Without it (an Activity that doesn't report them) the screen is half the +/// surface's size, the layout Bloom Jump was made for. +static NATIVE_PIXELS: AtomicBool = AtomicBool::new(false); +/// The surface's new pixel size (`w << 32 | h`) from `surfaceChanged`, applied +/// by the game thread at the start of its next frame; 0 = none. +static PENDING_SIZE: AtomicU64 = AtomicU64::new(0); +/// The surface's current pixel size (`w << 32 | h`). +static SURFACE_SIZE: AtomicU64 = AtomicU64::new(0); + +fn pack_size(w: u32, h: u32) -> u64 { + (w as u64) << 32 | h as u64 +} + +/// Resize the renderer to a surface of `w` x `h` pixels (game thread). +fn resize_to(eng: &mut EngineState, w: u32, h: u32) { + let (lw, lh) = logical_size(w, h); + eng.renderer.resize(w, h, lw, lh); + SURFACE_SIZE.store(pack_size(w, h), Ordering::SeqCst); +} +/// Back button: 1 = press Escape next frame, 2 = release it the frame after. +static BACK_STATE: AtomicU8 = AtomicU8::new(0); +/// The wgpu instance the device came from: a new surface (after the app comes +/// back from the background) must be created from the same one. +static INSTANCE: OnceLock = OnceLock::new(); +/// Set once bloom_init_window has created the engine. +static ENGINE_READY: AtomicBool = AtomicBool::new(false); + +/// The surface's lifecycle between the UI thread (SurfaceHolder callbacks) and +/// the game thread. When the app goes to the background Android destroys the +/// surface: the game thread drops its wgpu surface, then waits until a new +/// window arrives. +/// +/// Each `ANativeWindow` reference has one owner. `window` is the newest window +/// from the UI thread, and `in_use` the one the game thread renders into; the +/// game thread releases `in_use` when it drops the surface built on it, and +/// whoever replaces a `window` the game thread never took releases that. So a +/// window can't go away under a frame, even when the UI thread stops waiting +/// for a slow game thread. +struct SurfaceLife { + lost: bool, + window: usize, + in_use: usize, +} +static LIFE: Mutex = Mutex::new(SurfaceLife { + lost: false, + window: 0, + in_use: 0, +}); +static LIFE_CHANGED: Condvar = Condvar::new(); +/// The Activity is finishing (`nativeOnDestroy`): the game thread sets its +/// `should_close` from this at its next frame, or leaves its surface wait. +static CLOSE_REQUESTED: AtomicBool = AtomicBool::new(false); + +/// The logical (game-visible) size for a surface of `w` x `h` pixels. +fn logical_size(w: u32, h: u32) -> (u32, u32) { + if NATIVE_PIXELS.load(Ordering::Relaxed) { + (w.max(1), h.max(1)) + } else { + ((w / 2).max(1), (h / 2).max(1)) + } +} + fn engine() -> &'static mut EngineState { unsafe { ENGINE.get_mut().expect("Engine not initialized") } } @@ -144,7 +212,21 @@ pub extern "C" fn bloom_init_window( b"BloomEngine\0".as_ptr(), b"bloom_init_window: starting\0".as_ptr(), ); + // The window nativeSetSurface handed over, else the one Perry's + // runtime set (bloom_android_set_native_window). The game thread owns + // it from here (see SurfaceLife). + { + let mut life = LIFE.lock().unwrap(); + if life.window != 0 { + if !NATIVE_WINDOW.is_null() && NATIVE_WINDOW as usize != life.window { + ANativeWindow_release(NATIVE_WINDOW); + } + NATIVE_WINDOW = life.window as *mut libc::c_void; + } + life.in_use = NATIVE_WINDOW as usize; + } let window = NATIVE_WINDOW; + NATIVE_WINDOW = std::ptr::null_mut(); // If no native window was set, use requested dimensions with a headless surface let (pixel_w, pixel_h) = if !window.is_null() { ( @@ -154,13 +236,13 @@ pub extern "C" fn bloom_init_window( } else { (width as u32, height as u32) }; - // Logical size is half of physical: the game UI (fonts, layout constants) - // was sized for ~1170×540 landscape (iPhone-sized); on Android's 2340×1080 - // panel we'd otherwise render at native pixel size and everything looks - // half-scale. wgpu still renders to the full physical surface; only the - // `screen_width`/`screen_height` the game sees are halved. - let logical_w = (pixel_w / 2).max(1); - let logical_h = (pixel_h / 2).max(1); + // Without reported display metrics the logical size is half of physical: + // Bloom Jump's UI (fonts, layout constants) was sized for ~1170×540 + // landscape (iPhone-sized); on Android's 2340×1080 panel it would + // otherwise look half-scale. wgpu still renders to the full physical + // surface; only the `screen_width`/`screen_height` the game sees are + // halved. With them (NATIVE_PIXELS) the game sees pixels, as on iOS. + let (logical_w, logical_h) = logical_size(pixel_w, pixel_h); let backends = backend_choice(); { @@ -307,6 +389,9 @@ pub extern "C" fn bloom_init_window( Renderer::new(device, queue, surface, surface_config, logical_w, logical_h); renderer.set_device_negotiation_report(negotiation_report); let _ = ENGINE.set(EngineState::new(renderer)); + let _ = INSTANCE.set(instance); + SURFACE_SIZE.store(pack_size(pixel_w, pixel_h), Ordering::SeqCst); + ENGINE_READY.store(true, Ordering::SeqCst); __android_log_print( 3, b"BloomEngine\0".as_ptr(), @@ -389,16 +474,30 @@ pub extern "C" fn bloom_attach_hwnd(_hwnd_bits: f64, _width: f64, _height: f64) #[no_mangle] pub extern "C" fn bloom_close_window() { unsafe { + // a host-attached window (bloom_attach_native) if !NATIVE_WINDOW.is_null() { ANativeWindow_release(NATIVE_WINDOW); NATIVE_WINDOW = std::ptr::null_mut(); } + if let Some(eng) = ENGINE.get_mut() { + eng.renderer.surface = None; + } + let mut life = LIFE.lock().unwrap(); + if life.in_use != 0 { + ANativeWindow_release(life.in_use as *mut libc::c_void); + } + if life.window != 0 && life.window != life.in_use { + ANativeWindow_release(life.window as *mut libc::c_void); + } + life.in_use = 0; + life.window = 0; + LIFE_CHANGED.notify_all(); } } #[no_mangle] pub extern "C" fn bloom_window_should_close() -> f64 { - if engine().should_close { + if engine().should_close || CLOSE_REQUESTED.load(Ordering::SeqCst) { 1.0 } else { 0.0 @@ -409,46 +508,178 @@ pub extern "C" fn bloom_window_should_close() -> f64 { // Touch input polling (called from Perry's Android event pump) // ============================================================ -/// Called by Perry's Android layer when a touch event occurs. +/// Touches from the UI thread, applied by the game thread between frames +/// (`apply_touches`): (action, x, y, pointer id). +static TOUCHES: Mutex> = Mutex::new(Vec::new()); + +/// Called by Perry's Android layer when a touch event occurs (UI thread). The +/// game thread reads its input state while the UI thread delivers touches, so +/// a touch is queued and applied between frames, as on iOS where touches +/// arrive on the game's own thread between frames. A down and up within one +/// frame then show as a touch for exactly one frame (`release_touch`) instead +/// of being released before the game read them. #[no_mangle] pub extern "C" fn bloom_android_on_touch(action: i32, x: f64, y: f64, pointer_index: i32) { - unsafe { - if let Some(eng) = ENGINE.get_mut() { - let sw = eng.screen_width(); - let sh = eng.screen_height(); - let lx = x * 0.5; - let ly = y * 0.5; - let msg = std::ffi::CString::new(format!( - "touch a={} raw=({},{}) scaled=({},{}) sw={} sh={}", - action, x, y, lx, ly, sw, sh - )) - .unwrap(); - __android_log_print(3, b"BloomTouch\0".as_ptr(), b"%s\0".as_ptr(), msg.as_ptr()); - eng.input.set_mouse_position(lx, ly); - if action == 1 || action == 3 { - eng.input.release_touch(pointer_index as usize, lx, ly); // UP / CANCEL - } else { - eng.input.set_touch(pointer_index as usize, lx, ly, true); // DOWN / MOVE - } - match action { - 0 => eng.input.set_mouse_button_down(0), // ACTION_DOWN - 1 => eng.input.set_mouse_button_up(0), // ACTION_UP - 2 => {} // ACTION_MOVE - _ => {} + if let Ok(mut q) = TOUCHES.lock() { + q.push((action, x, y, pointer_index)); + } +} + +/// Apply the queued touches (game thread), after `end_frame`: where iOS's +/// touches land, between one frame and the game's input read for the next. +/// A tap's down and up then show as one frame with the finger down, and the +/// release in `end_frame` after it. +fn apply_touches() { + if !ENGINE_READY.load(Ordering::SeqCst) { + return; + } + let touches = TOUCHES + .lock() + .map(|mut q| std::mem::take(&mut *q)) + .unwrap_or_default(); + let eng = engine(); + for (action, x, y, id) in touches { + apply_touch(eng, action, x, y, id); + } +} + +/// Apply one queued touch (game thread). `action` is MotionEvent's masked +/// action: DOWN 0, UP 1, MOVE 2, CANCEL 3, POINTER_DOWN 5, POINTER_UP 6. +/// The first finger also drives mouse button 0. +fn apply_touch(eng: &mut EngineState, action: i32, x: f64, y: f64, pointer_index: i32) { + let k = if NATIVE_PIXELS.load(Ordering::Relaxed) { + 1.0 + } else { + 0.5 + }; + let lx = x * k; + let ly = y * k; + let index = pointer_index as usize; + match action { + // CANCEL: the gesture is gone, every finger with it. + 3 => { + for i in 0..eng.input.max_touch_points() { + if eng.input.is_touch_active(i) { + let (tx, ty) = (eng.input.get_touch_x(i), eng.input.get_touch_y(i)); + eng.input.release_touch(i, tx, ty); + } } + eng.input.set_mouse_button_up(0); + return; } + 1 | 6 => eng.input.release_touch(index, lx, ly), + _ => eng.input.set_touch(index, lx, ly, true), + } + eng.input.set_mouse_position(lx, ly); + match action { + 0 => eng.input.set_mouse_button_down(0), + 1 => eng.input.set_mouse_button_up(0), + _ => {} } } #[no_mangle] pub extern "C" fn bloom_begin_drawing() { + android_frame_sync(); engine().begin_frame(); } +/// What the UI thread asked for since the last frame, applied on the game +/// thread before the frame starts: the Back button, a new surface size, and the +/// surface going away (the app in the background) and coming back. +fn android_frame_sync() { + if !ENGINE_READY.load(Ordering::SeqCst) { + return; + } + let eng = engine(); + match BACK_STATE.load(Ordering::SeqCst) { + 1 => { + eng.input.inject_key_down(27); // Key.ESCAPE + BACK_STATE.store(2, Ordering::SeqCst); + } + 2 => { + eng.input.inject_key_up(27); + // unless nativeOnBack queued the next press meanwhile + let _ = BACK_STATE.compare_exchange(2, 0, Ordering::SeqCst, Ordering::SeqCst); + } + _ => {} + } + + if CLOSE_REQUESTED.load(Ordering::SeqCst) { + eng.should_close = true; + } + + let mut life = LIFE.lock().unwrap(); + if life.lost || life.window != life.in_use { + // Drop the wgpu surface and let its window go, tell the UI thread, + // then wait (frames, timers and audio decoding stop with us) until a + // new window arrives or the Activity is destroyed. + if life.in_use != 0 { + eng.renderer.surface = None; + unsafe { ANativeWindow_release(life.in_use as *mut libc::c_void) }; + life.in_use = 0; + let size = SURFACE_SIZE.load(Ordering::SeqCst); + resize_to(eng, (size >> 32) as u32, size as u32); // an offscreen target meanwhile + LIFE_CHANGED.notify_all(); + } + if life.lost { + AUDIO_PAUSED.store(true, Ordering::SeqCst); + while life.lost && !CLOSE_REQUESTED.load(Ordering::SeqCst) { + life = LIFE_CHANGED + .wait_timeout(life, std::time::Duration::from_millis(250)) + .unwrap() + .0; + } + AUDIO_PAUSED.store(false, Ordering::SeqCst); + } + if life.window != 0 { + // Sizes reported before the window is measured are stale; any + // reported after it are applied below. + PENDING_SIZE.store(0, Ordering::SeqCst); + let window = life.window as *mut libc::c_void; + if let Some(instance) = INSTANCE.get() { + let handle = raw_window_handle::AndroidNdkWindowHandle::new( + std::ptr::NonNull::new(window).unwrap(), + ); + let surface = unsafe { + instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: Some(raw_window_handle::RawDisplayHandle::Android( + raw_window_handle::AndroidDisplayHandle::new(), + )), + raw_window_handle: raw_window_handle::RawWindowHandle::AndroidNdk(handle), + }) + }; + match surface { + Ok(s) => { + eng.renderer.surface = Some(s); + life.in_use = life.window; + let (w, h) = unsafe { + ( + ANativeWindow_getWidth(window) as u32, + ANativeWindow_getHeight(window) as u32, + ) + }; + resize_to(eng, w, h); // configures the new surface + } + // Rendered offscreen; the next frame tries again. + Err(e) => log::error!("resume: create_surface failed: {e}"), + } + } + } + } + drop(life); + + let size = PENDING_SIZE.swap(0, Ordering::SeqCst); + if size != 0 { + resize_to(eng, (size >> 32) as u32, size as u32); + } +} + #[no_mangle] pub extern "C" fn bloom_end_drawing() { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { engine().end_frame(); + apply_touches(); })); if let Err(e) = result { let msg = if let Some(s) = e.downcast_ref::<&str>() { @@ -521,7 +752,9 @@ fn android_audio_thread(renderer: Option) { *s = 0.0; } if let Some(r) = self.renderer.as_mut() { - r.mix(interleaved); + if !AUDIO_PAUSED.load(Ordering::SeqCst) { + r.mix(interleaved); + } } if AUDIO_RUNNING.load(Ordering::SeqCst) { DataCallbackResult::Continue @@ -542,9 +775,22 @@ fn android_audio_thread(renderer: Option) { match stream { Ok(ref mut s) => { - let _ = s.start(); - // Keep thread alive while audio is running + // Keep thread alive while audio is running; pause the stream while + // the app is in the background. A start or pause that fails is + // tried again on the next tick. + let mut playing = false; while AUDIO_RUNNING.load(Ordering::SeqCst) { + let want = !AUDIO_PAUSED.load(Ordering::SeqCst); + if want != playing { + let done = if want { s.start() } else { s.pause() }; + match done { + Ok(()) => playing = want, + Err(e) => log::warn!( + "audio: {} failed: {e:?}", + if want { "start" } else { "pause" } + ), + } + } std::thread::sleep(std::time::Duration::from_millis(100)); } let _ = s.stop(); @@ -706,6 +952,93 @@ fn install_panic_logger() { })); } +/// An app process's stdout and stderr go to /dev/null on Android, so the +/// game's console.log / console.error (and a Perry runtime error's report) +/// would be lost. Each goes into a pipe instead, and a thread writes its lines +/// to logcat under tag "BloomStdout": stdout's at info priority, stderr's at +/// error priority. +fn route_stdio_to_logcat() { + // line-buffered, so a line shows up when it is written + unsafe { libc::setvbuf(stdout_file(), std::ptr::null_mut(), libc::_IOLBF, 0) }; + route_fd_to_logcat(1, 4, "bloom-stdout"); + route_fd_to_logcat(2, 6, "bloom-stderr"); +} + +/// Point `fd` at a pipe whose lines a new thread writes to logcat at +/// `priority`. `fd` stays as it was when the pipe or the thread can't be made. +fn route_fd_to_logcat(fd: i32, priority: i32, thread: &str) { + unsafe { + let mut fds = [0i32; 2]; + if libc::pipe(fds.as_mut_ptr()) != 0 { + return; + } + // Above the standard descriptors, in case the host closed one of them + // and pipe() reused it; dup2 below must not replace the pipe's ends. + let [read_fd, write_fd] = fds.map(|f| { + let moved = libc::fcntl(f, libc::F_DUPFD_CLOEXEC, 3); + libc::close(f); + moved + }); + if read_fd < 0 || write_fd < 0 { + for f in [read_fd, write_fd] { + if f >= 0 { + libc::close(f); + } + } + return; + } + // The reader first: a pipe nobody drains blocks its writers once full. + let reader = std::thread::Builder::new() + .name(thread.into()) + .spawn(move || { + let mut buf = [0u8; 4096]; + let mut line: Vec = Vec::new(); + let emit = |line: &mut Vec| { + line.push(0); + __android_log_print( + priority, + b"BloomStdout\0".as_ptr(), + b"%s\0".as_ptr(), + line.as_ptr(), + ); + line.clear(); + }; + loop { + let n = libc::read(read_fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()); + if n <= 0 { + break; + } + for &b in &buf[..n as usize] { + if b == b'\n' { + emit(&mut line); + continue; + } + if line.len() >= 1000 { + emit(&mut line); + } + // %s would end the line at a NUL + line.push(if b == 0 { b'?' } else { b }); + } + } + }); + if reader.is_err() { + libc::close(read_fd); + libc::close(write_fd); + return; + } + libc::dup2(write_fd, fd); + libc::close(write_fd); + } +} + +/// C's `stdout` FILE*, for setvbuf. +unsafe fn stdout_file() -> *mut libc::FILE { + extern "C" { + static mut stdout: *mut libc::FILE; + } + stdout +} + /// JNI_OnLoad: called when System.loadLibrary() loads this .so. /// Disables MTE heap tagging (required for Perry NaN-boxing) and /// reads the asset base path from BLOOM_ASSET_PATH env var. @@ -720,6 +1053,8 @@ pub extern "C" fn JNI_OnLoad(_vm: *mut libc::c_void, _reserved: *mut libc::c_voi .with_tag("BloomWGPU"), ); + route_stdio_to_logcat(); + // Perry-runtime installs its own panic hook once `main()` runs, so this one // gets overridden; `bloom_init_window` re-installs it (see // `install_panic_logger`) after perry is up and before material compile. @@ -786,7 +1121,105 @@ pub unsafe extern "C" fn Java_com_bloomengine_game_BloomGameBridge_nativeSetSurf b"BloomEngine\0".as_ptr(), b"nativeSetSurface: ANativeWindow acquired\0".as_ptr(), ); - bloom_android_set_native_window(window); + // ANativeWindow_fromSurface already holds a reference for us, which the + // game thread takes over (see SurfaceLife). A window it never took is + // replaced here. + let mut life = LIFE.lock().unwrap(); + if life.window != 0 && life.window != life.in_use { + ANativeWindow_release(life.window as *mut libc::c_void); + } + life.window = window as usize; + life.lost = false; + LIFE_CHANGED.notify_all(); +} + +/// The surface is going away (the app went to the background, or the Activity +/// is finishing). Waits until the game thread has dropped its wgpu surface and +/// let the window go. Call from SurfaceHolder.Callback.surfaceDestroyed; +/// a later nativeSetSurface resumes the game on the new surface. +#[no_mangle] +pub unsafe extern "C" fn Java_com_bloomengine_game_BloomGameBridge_nativeSurfaceLost( + _env: *mut libc::c_void, + _class: *mut libc::c_void, +) { + let mut life = LIFE.lock().unwrap(); + let window = life.window; + life.lost = true; + life.window = 0; + LIFE_CHANGED.notify_all(); + if window == 0 { + return; + } + if window != life.in_use { + // the game thread never took it + ANativeWindow_release(window as *mut libc::c_void); + return; + } + if ENGINE_READY.load(Ordering::SeqCst) { + // Wait for the game thread to drop its surface, so nothing renders + // into the window once this returns. A frame takes milliseconds; the + // bound only keeps a stuck game thread from blocking the UI thread into + // an ANR (the game thread still holds its reference to the window). + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while life.in_use == window { + let now = std::time::Instant::now(); + if now >= deadline { + log::warn!("nativeSurfaceLost: the game thread didn't release the surface"); + break; + } + life = LIFE_CHANGED.wait_timeout(life, deadline - now).unwrap().0; + } + } +} + +/// The surface's new size in pixels (SurfaceHolder.Callback.surfaceChanged: +/// rotation, split screen, a foldable opening). +#[no_mangle] +pub extern "C" fn Java_com_bloomengine_game_BloomGameBridge_nativeOnResize( + _env: *mut libc::c_void, + _class: *mut libc::c_void, + width: i32, + height: i32, +) { + if width > 0 && height > 0 { + PENDING_SIZE.store(pack_size(width as u32, height as u32), Ordering::SeqCst); + } +} + +/// The display's density (pixels per dp) and the safe-area insets in pixels +/// (system bars, display cutout). Once called, the game sees device pixels as +/// on iOS: getScreenWidth() is the surface's width in pixels and +/// getDisplayScale() is `density`. Call before nativeMain and whenever the +/// insets change. +#[no_mangle] +pub extern "C" fn Java_com_bloomengine_game_BloomGameBridge_nativeSetDisplayMetrics( + _env: *mut libc::c_void, + _class: *mut libc::c_void, + density: f64, + top: f64, + right: f64, + bottom: f64, + left: f64, +) { + NATIVE_PIXELS.store(true, Ordering::SeqCst); + bloom_shared::display::set_display_scale(density); + bloom_shared::display::set_safe_area_insets(top, right, bottom, left); +} + +/// The Back button. Returns false when the game is at its top level +/// (setMenuButtonExits(true), as for the Apple TV's Menu button): the Activity +/// then does the system's Back (leaves the app). Otherwise the game gets an +/// Escape press and this returns true. +#[no_mangle] +pub extern "C" fn Java_com_bloomengine_game_BloomGameBridge_nativeOnBack( + _env: *mut libc::c_void, + _class: *mut libc::c_void, +) -> u8 { + if bloom_shared::display::menu_exits() || !ENGINE_READY.load(Ordering::SeqCst) { + return 0; + } + BACK_STATE.store(1, Ordering::SeqCst); + 1 } /// Run the compiled game's main() function on the game thread. @@ -830,11 +1263,9 @@ pub extern "C" fn Java_com_bloomengine_game_BloomGameBridge_nativeOnDestroy( _env: *mut libc::c_void, _class: *mut libc::c_void, ) { - unsafe { - if let Some(eng) = ENGINE.get_mut() { - eng.should_close = true; - } - } + CLOSE_REQUESTED.store(true, Ordering::SeqCst); + let _life = LIFE.lock().unwrap(); + LIFE_CHANGED.notify_all(); } // ============================================================