From 154556ba0eaf74854c7aade71ea120ee445be07b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 8 Nov 2026 20:14:57 +0100 Subject: [PATCH 1/3] android: device pixels, rotation, background and resume, Back, touches between frames What a full app (the web3d chess game on @bloomengine/web) needs on Android to behave as on iOS. All of it is opt-in from the Activity, so an app that doesn't call the new entry points (Bloom Jump) keeps its half-size layout. - Device pixels: `nativeSetDisplayMetrics(density, insets)` switches the game to the surface's pixel size, with getDisplayScale() the display density and the safe area the camera cutout, as on iOS. - Rotation: `nativeOnResize` hands the new surface size to the game thread, which resizes at the start of its next frame. - Background: `nativeSurfaceLost` waits until the game thread has dropped its wgpu surface before the window is released; the game renders headless and its audio pauses until `nativeSetSurface` brings a new window, made into a surface from the same wgpu instance. - Back: `nativeOnBack` presses Escape for a frame, or returns false where the app says the Menu button would leave (setMenuButtonExits), so the Activity hands it to the system. - Touches: the UI thread queues them and the game thread applies them after end_frame, where iOS's touches land. They were written into the input state while the game thread was mid-frame, so a quick tap (down and up within one frame) could be released before the game read it. --- native/android/src/lib.rs | 334 +++++++++++++++++++++++++++++++++----- 1 file changed, 296 insertions(+), 38 deletions(-) diff --git a/native/android/src/lib.rs b/native/android/src/lib.rs index 85c4bedd..e8681c37 100644 --- a/native/android/src/lib.rs +++ b/native/android/src/lib.rs @@ -3,14 +3,72 @@ 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 (`dropped`) before the UI +/// thread lets the window go, then waits until a new window arrives. +struct SurfaceLife { + lost: bool, + dropped: bool, + window: usize, +} +static LIFE: Mutex = Mutex::new(SurfaceLife { + lost: false, + dropped: false, + window: 0, +}); +static LIFE_CHANGED: Condvar = Condvar::new(); + +/// 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") } } @@ -154,13 +212,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 +365,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(), @@ -409,46 +470,145 @@ 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). +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; + 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 + _ => {} // MOVE, CANCEL } } #[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); + BACK_STATE.store(0, Ordering::SeqCst); + } + _ => {} + } + + let mut life = LIFE.lock().unwrap(); + if life.lost { + // Drop the wgpu surface while its window still exists, tell the UI + // thread, then wait (frames, timers and audio decoding stop with us) + // until a new window arrives or the Activity is destroyed. + eng.renderer.surface = None; + let size = SURFACE_SIZE.load(Ordering::SeqCst); + resize_to(eng, (size >> 32) as u32, size as u32); // an offscreen target meanwhile + life.dropped = true; + AUDIO_PAUSED.store(true, Ordering::SeqCst); + LIFE_CHANGED.notify_all(); + while life.lost && !eng.should_close { + life = LIFE_CHANGED + .wait_timeout(life, std::time::Duration::from_millis(250)) + .unwrap() + .0; + } + life.dropped = false; + AUDIO_PAUSED.store(false, Ordering::SeqCst); + if !life.lost && life.window != 0 { + 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); + let (w, h) = unsafe { + ( + ANativeWindow_getWidth(window) as u32, + ANativeWindow_getHeight(window) as u32, + ) + }; + resize_to(eng, w, h); // configures the new surface + PENDING_SIZE.store(0, Ordering::SeqCst); + } + 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 +681,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 @@ -543,8 +705,15 @@ 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 + let mut paused = false; while AUDIO_RUNNING.load(Ordering::SeqCst) { + let want = AUDIO_PAUSED.load(Ordering::SeqCst); + if want != paused { + paused = want; + let _ = if paused { s.pause() } else { s.start() }; + } std::thread::sleep(std::time::Duration::from_millis(100)); } let _ = s.stop(); @@ -786,7 +955,96 @@ 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 + NATIVE_WINDOW = window; + // After nativeSurfaceLost: the waiting game thread picks the new window up. + let mut life = LIFE.lock().unwrap(); + 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, +/// then lets 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(); + life.lost = true; + life.window = 0; + LIFE_CHANGED.notify_all(); + if ENGINE_READY.load(Ordering::SeqCst) { + // a frame takes milliseconds; the bound only keeps a stuck game thread + // from blocking the UI thread into an ANR + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while !life.dropped { + 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; + } + } + drop(life); + if !NATIVE_WINDOW.is_null() { + ANativeWindow_release(NATIVE_WINDOW); + NATIVE_WINDOW = std::ptr::null_mut(); + } +} + +/// 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. From 042e5539b7360e85b9a41df8855834a3c565f7e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 06:44:19 +0100 Subject: [PATCH 2/3] android: the game's stdout and stderr go to logcat (tag BloomStdout) An app process's stdout and stderr go to /dev/null on Android, so a game's console.log / console.error, and a Perry runtime error's report, were lost. JNI_OnLoad now points both at a pipe whose lines a thread writes to logcat. --- native/android/src/lib.rs | 50 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/native/android/src/lib.rs b/native/android/src/lib.rs index e8681c37..e1d83499 100644 --- a/native/android/src/lib.rs +++ b/native/android/src/lib.rs @@ -875,6 +875,54 @@ 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. Both go into a pipe instead, and a thread writes each line +/// to logcat: tag "BloomStdout", info for stdout's and stderr's alike. +fn route_stdio_to_logcat() { + unsafe { + let mut fds = [0i32; 2]; + if libc::pipe(fds.as_mut_ptr()) != 0 { + return; + } + // line-buffered, so a line shows up when it is written + libc::setvbuf(stdout_file(), std::ptr::null_mut(), libc::_IOLBF, 0); + libc::dup2(fds[1], 1); + libc::dup2(fds[1], 2); + libc::close(fds[1]); + let read_fd = fds[0]; + let _ = std::thread::Builder::new().name("bloom-stdout".into()).spawn(move || { + let mut buf = [0u8; 4096]; + let mut line: Vec = Vec::new(); + 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' || line.len() >= 1000 { + line.push(0); + __android_log_print(4, b"BloomStdout\0".as_ptr(), b"%s\0".as_ptr(), line.as_ptr()); + line.clear(); + if b == b'\n' { + continue; + } + } + line.push(b); + } + } + }); + } +} + +/// 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. @@ -889,6 +937,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. From a264a61fe422332705602fac374f4f37ab3b5517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 11:54:34 +0100 Subject: [PATCH 3/3] android: one owner per window reference; cancel, pointer-up and Back races; audio and log fixes - The game thread owns the ANativeWindow it renders into and releases it when it drops the surface; nativeSurfaceLost releases only a window the game never took. A slow game thread can no longer render into a released window, and a new surface arriving before the game handled the loss is picked up (the game compares its window with the newest one). - nativeOnDestroy sets an atomic the game thread reads, instead of writing EngineState from the UI thread. - A failed surface creation renders offscreen and retries next frame. - Sizes reported while the restored window is measured are kept. - Touches: POINTER_UP (6) releases its finger; CANCEL (3) releases every finger and mouse button 0. - Back: the release clears the state only if no new press came in. - Audio: a failed start or pause is retried. - Logcat: stderr at error priority (its own pipe); the reader thread starts before stdout/stderr are redirected; pipe ends kept above fd 2; NUL bytes don't cut a line. --- native/android/src/lib.rs | 291 +++++++++++++++++++++++++++----------- 1 file changed, 207 insertions(+), 84 deletions(-) diff --git a/native/android/src/lib.rs b/native/android/src/lib.rs index e1d83499..bd99fac8 100644 --- a/native/android/src/lib.rs +++ b/native/android/src/lib.rs @@ -46,19 +46,29 @@ 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 (`dropped`) before the UI -/// thread lets the window go, then waits until a new window arrives. +/// 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, - dropped: bool, window: usize, + in_use: usize, } static LIFE: Mutex = Mutex::new(SurfaceLife { lost: false, - dropped: 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) { @@ -202,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() { ( @@ -450,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 @@ -495,28 +533,48 @@ 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 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). +/// 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 k = if NATIVE_PIXELS.load(Ordering::Relaxed) { + 1.0 + } else { + 0.5 + }; let lx = x * k; let ly = y * k; - 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 + 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), // ACTION_DOWN - 1 => eng.input.set_mouse_button_up(0), // ACTION_UP - _ => {} // MOVE, CANCEL + 0 => eng.input.set_mouse_button_down(0), + 1 => eng.input.set_mouse_button_up(0), + _ => {} } } @@ -541,31 +599,43 @@ fn android_frame_sync() { } 2 => { eng.input.inject_key_up(27); - BACK_STATE.store(0, Ordering::SeqCst); + // 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 { - // Drop the wgpu surface while its window still exists, tell the UI - // thread, then wait (frames, timers and audio decoding stop with us) - // until a new window arrives or the Activity is destroyed. - eng.renderer.surface = None; - let size = SURFACE_SIZE.load(Ordering::SeqCst); - resize_to(eng, (size >> 32) as u32, size as u32); // an offscreen target meanwhile - life.dropped = true; - AUDIO_PAUSED.store(true, Ordering::SeqCst); - LIFE_CHANGED.notify_all(); - while life.lost && !eng.should_close { - life = LIFE_CHANGED - .wait_timeout(life, std::time::Duration::from_millis(250)) - .unwrap() - .0; + 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); } - life.dropped = false; - AUDIO_PAUSED.store(false, Ordering::SeqCst); - if !life.lost && life.window != 0 { + 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( @@ -582,6 +652,7 @@ fn android_frame_sync() { match surface { Ok(s) => { eng.renderer.surface = Some(s); + life.in_use = life.window; let (w, h) = unsafe { ( ANativeWindow_getWidth(window) as u32, @@ -589,8 +660,8 @@ fn android_frame_sync() { ) }; resize_to(eng, w, h); // configures the new surface - PENDING_SIZE.store(0, Ordering::SeqCst); } + // Rendered offscreen; the next frame tries again. Err(e) => log::error!("resume: create_surface failed: {e}"), } } @@ -704,15 +775,21 @@ fn android_audio_thread(renderer: Option) { match stream { Ok(ref mut s) => { - let _ = s.start(); // Keep thread alive while audio is running; pause the stream while - // the app is in the background - let mut paused = false; + // 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 != paused { - paused = want; - let _ = if paused { s.pause() } else { s.start() }; + 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)); } @@ -877,41 +954,80 @@ 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. Both go into a pipe instead, and a thread writes each line -/// to logcat: tag "BloomStdout", info for stdout's and stderr's alike. +/// 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; } - // line-buffered, so a line shows up when it is written - libc::setvbuf(stdout_file(), std::ptr::null_mut(), libc::_IOLBF, 0); - libc::dup2(fds[1], 1); - libc::dup2(fds[1], 2); - libc::close(fds[1]); - let read_fd = fds[0]; - let _ = std::thread::Builder::new().name("bloom-stdout".into()).spawn(move || { - let mut buf = [0u8; 4096]; - let mut line: Vec = Vec::new(); - loop { - let n = libc::read(read_fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()); - if n <= 0 { - break; + // 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); } - for &b in &buf[..n as usize] { - if b == b'\n' || line.len() >= 1000 { - line.push(0); - __android_log_print(4, b"BloomStdout\0".as_ptr(), b"%s\0".as_ptr(), line.as_ptr()); - line.clear(); + } + 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 }); } - line.push(b); } - } - }); + }); + if reader.is_err() { + libc::close(read_fd); + libc::close(write_fd); + return; + } + libc::dup2(write_fd, fd); + libc::close(write_fd); } } @@ -1005,18 +1121,21 @@ pub unsafe extern "C" fn Java_com_bloomengine_game_BloomGameBridge_nativeSetSurf b"BloomEngine\0".as_ptr(), b"nativeSetSurface: ANativeWindow acquired\0".as_ptr(), ); - // ANativeWindow_fromSurface already holds a reference for us - NATIVE_WINDOW = window; - // After nativeSurfaceLost: the waiting game thread picks the new window up. + // 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, -/// then lets the window go. Call from SurfaceHolder.Callback.surfaceDestroyed; +/// 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( @@ -1024,14 +1143,25 @@ pub unsafe extern "C" fn Java_com_bloomengine_game_BloomGameBridge_nativeSurface _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) { - // a frame takes milliseconds; the bound only keeps a stuck game thread - // from blocking the UI thread into an ANR + // 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.dropped { + 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"); @@ -1040,11 +1170,6 @@ pub unsafe extern "C" fn Java_com_bloomengine_game_BloomGameBridge_nativeSurface life = LIFE_CHANGED.wait_timeout(life, deadline - now).unwrap().0; } } - drop(life); - if !NATIVE_WINDOW.is_null() { - ANativeWindow_release(NATIVE_WINDOW); - NATIVE_WINDOW = std::ptr::null_mut(); - } } /// The surface's new size in pixels (SurfaceHolder.Callback.surfaceChanged: @@ -1138,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(); } // ============================================================