diff --git a/README.md b/README.md index 7801aec..27fe870 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ v0.1.x shipped the runtime services only. The runtime extraction, phase 1a–1d | `relayout` | `forceWindowRelayout(activity)`: re-apply the window attributes so a stuck 1x1 restored window relayouts (UI thread only) | bgfx #127 | | `aaudio` | the AAudio output device (`ensureStarted` / `stop` / `framesMixed`): the `labelle-audio` `DeviceSink` a backend's PCM mixer drives; PCM_I16 stereo 48 kHz. `ensureStarted` opens the stream and only *requests* the start — it never blocks on the audio server (no `AAudioStream_waitForStateChange`). A device-owned control thread logs `android: AAudio stream started (…)` once per start, on the stream's **first mixer pull** (its first data callback) — which may precede AAudio's own STARTED state flip (the legacy AudioTrack path pre-fills first), and never on `requestStart` alone. A device disconnect (headset / Bluetooth route change) is reported by the error callback and the control thread closes and reopens the stream **itself**, with no further `ensureStarted`, logging `android: AAudio stream disconnected; reopening`; a reopen that fails is retried on the control thread with exponential backoff (100 ms doubling to 5 s, ~60 s in total), then given up (one `giving up` line) until the next `ensureStarted`. Setup failures warn once per failure episode and otherwise retry silently. Pure NDK C API, links `libaaudio` | bgfx #306 | | `immersive` | `enable(activity)` / `applyUiThread(activity)`: hide the status and navigation bars in immersive-sticky mode (`WindowInsetsController` on API 30+, `setSystemUiVisibility` on API 28/29). `enable` chains `onContentRectChanged`/`onWindowFocusChanged` for sokol; `applyUiThread` is the UI-thread entry the bgfx shell calls on each focus gain. The generated `main.zig` passes the activity from labelle-core's backend seam | labelle-engine `src/android.zig` (engine#902) | +| `activity` | `finish(activity)`: the game quit (`game.quit()`), so finish the activity (`ANativeActivity_finish`; call from the glue app thread while the activity holds its window — see `activity.zig`) and, once the backend's normal destroy teardown has run, end the process (`_exit(0)`) so the next launch is a cold start rather than a restore of a quit game. First call per process only | FP #979 | | `video` | MediaCodec decode: `VideoDecoder` (AMediaExtractor + AMediaCodec → AImageReader YUV_420_888 on a worker thread, a ring of tightened Y/U/V planes for the backend's GPU-YUV upload) and `decodeTrack` (the mp4's audio track → 48 kHz stereo i16 `Pcm`: multichannel layouts are downmixed ITU-R BS.775-style — L' = L + 0.707·C + 0.707·Ls, R' likewise, LFE dropped, normalised so it cannot clip; mono duplicated, stereo untouched; the layout comes from the output format's `channel-mask`, else the channel count's default — and the PCM is placed on the media timeline from the track's first presentation time: silence prepended when the audio starts late, the head trimmed when it starts early, so PCM frame 0 is media time 0, the player's clock origin), plus the pure, host-tested `yuv` (CPU YUV→RGBA; `Matrix` BT.601/BT.709, limited/full range — the decoder picks it from the stream's `ColorSpace`) and `planes` (row de-pad / NV12 de-interleave) helpers the desktop decoders share; pure NDK C API, links `libmediandk` | bgfx `src/video/{android,android_audio,yuv,planes}.zig` (FP #549) | Every JNI service takes the running `ANativeActivity*` as an opaque pointer; the JNI walks (`src/jni/*.c`) read `->vm` / `->clazz` through the NDK's own header, so both backends pass what they already hold. `aaudio` declares its `MixCallback` structurally (`*const fn (out: []i16, channels: u8) void`) so the package has no dependency on labelle-audio; the consumer asserts equality at comptime. `build.zig` also exports `addAndroidSysroot` / `resolveNdk` / `nativeAppGlueDir` / `isAndroidTarget` for consumers' build scripts. diff --git a/build.zig b/build.zig index d5f5bb5..748de63 100644 --- a/build.zig +++ b/build.zig @@ -137,6 +137,7 @@ pub fn build(b: *std.Build) void { "src/jni/debuggable.c", "src/jni/window_relayout.c", "src/jni/renderer_query.c", + "src/jni/activity_finish.c", }, .flags = &.{ "-std=c11", "-Wall" }, }); diff --git a/src/activity.zig b/src/activity.zig new file mode 100644 index 0000000..a538cc5 --- /dev/null +++ b/src/activity.zig @@ -0,0 +1,154 @@ +//! Finish the running activity when the game quits +//! (moca-tecnologia/flying-platform-labelle#979). +//! +//! `game.quit()` only flips the engine's `running` flag; the backend's frame +//! loop forwards that to `window.requestQuit()`. On desktop that closes the +//! window and the process ends. On Android a NativeActivity is not closed by +//! its native code returning: the backend's shell loop runs until the +//! framework destroys the activity. `finish` asks for that. +//! +//! ## What `finish` does +//! +//! 1. Chains the activity's `onDestroy` callback: the glue's own +//! `onDestroy` runs first (it waits for the backend's `android_main` to +//! return), then the process ends with `_exit(0)`. +//! 2. `ANativeActivity_finish(activity)`: posts to the UI thread, which calls +//! `Activity.finish()`. The usual lifecycle follows (PAUSE, TERM_WINDOW, +//! STOP, DESTROY), so the backend tears down exactly as it does when the +//! user backs out. +//! +//! ## Calling contract (why the `onDestroy` write is race-free) +//! +//! `finish` MUST be called from the android_native_app_glue **app thread** +//! (the thread running `android_main`), **while the activity has its window** +//! — i.e. after the glue delivered APP_CMD_INIT_WINDOW and before the caller +//! has processed APP_CMD_TERM_WINDOW. The bgfx shell's call site (after a +//! frame, only while it owns a live surface) satisfies this. +//! +//! Step 1 writes `callbacks->onDestroy`, which the UI thread reads when the +//! activity is destroyed. The contract orders that write before the read: +//! +//! * `NativeActivity.onDestroy` (UI thread) destroys the surface BEFORE it +//! calls the native `onDestroy` (`onSurfaceDestroyedNative`, then +//! `unloadNativeCode`). Any window the activity has is therefore torn down +//! first, on the same thread. +//! * The glue's `onNativeWindowDestroyed` (`android_app_set_window(NULL)`) +//! posts APP_CMD_TERM_WINDOW and blocks on the glue mutex/condvar until +//! the app thread has processed it (`android_app_post_exec_cmd` sets +//! `window = NULL` and broadcasts under that mutex). +//! * The app thread only processes TERM_WINDOW after `finish` returned +//! (program order), so the writes happen-before the unlock, which +//! happens-before the UI thread leaving `onNativeWindowDestroyed`, which +//! precedes its read of `callbacks->onDestroy`. +//! +//! So while the caller holds the window, no destruction can be concurrently +//! reading the slot. A caller on another thread, or one without a window, +//! gets no such ordering: don't. (A UI-thread install at `onCreate` would not +//! need the contract, but `ANativeActivity_onCreate` belongs to the backend's +//! glue, not to this package.) +//! +//! ## Why the process ends +//! +//! The engine is a process global that outlives an activity instance +//! (labelle-bgfx#143: a relaunch into a cached process restores the running +//! game rather than booting a new one). After a quit its `running` flag stays +//! false, so the next launch would restore a quit game and finish at once: the +//! app would look like it cannot start. Ending the process makes the next +//! launch a cold start, which is what quitting means on desktop too. sokol's +//! Android backend does the same (`exit(0)` at the end of its `onDestroy`). +//! +//! `_exit`, not `exit`: the engine's worker threads (asset loading, audio) +//! are still running, and `exit` would run C++ static destructors under them. +//! Nothing needs flushing — logging goes straight to logcat. +//! +//! Only the first successful call in a process does anything; later calls +//! return `false` without touching the activity. +const is_android = @import("root.zig").is_android; + +/// `jni/activity_finish.c`. Declared unconditionally (extern decls are only +/// linked when referenced); only the Android branch references it. +extern "c" fn labelle_android_finish_activity(activity: *anyopaque) c_int; + +/// Process-wide once-guard. Only ever touched through `request`'s atomics. +var requested: bool = false; + +/// Finish `activity` and end the process once it has been destroyed. See the +/// file doc for the calling contract (glue app thread, window held). Returns +/// true when this call requested the finish; false for a null activity, a +/// repeated call, or off Android (where it is a no-op). +pub fn finish(activity: ?*anyopaque) bool { + if (comptime !is_android) return false; + return request(&requested, activity, jniFinish); +} + +fn jniFinish(activity: *anyopaque) bool { + return labelle_android_finish_activity(activity) != 0; +} + +/// The once-guard, split from the JNI half so a HOST test can drive it. +/// +/// * null `activity` → false, guard untouched (a later real call still runs). +/// * guard already taken → false, `doFinish` not called. +/// * otherwise take the guard with one cmpxchg, then call `doFinish`. If it +/// reports that nothing was done (no callbacks table), release the guard +/// so a later call can still finish. +fn request(flag: *bool, activity: ?*anyopaque, comptime doFinish: fn (*anyopaque) bool) bool { + const a = activity orelse return false; + if (@cmpxchgStrong(bool, flag, false, true, .acq_rel, .acquire) != null) return false; + if (doFinish(a)) return true; + @atomicStore(bool, flag, false, .release); + return false; +} + +test "finish is a no-op off Android" { + const testing = @import("std").testing; + if (comptime is_android) return error.SkipZigTest; + var dummy: u8 = 0; + try testing.expect(!finish(null)); + try testing.expect(!finish(@ptrCast(&dummy))); +} + +const TestFinish = struct { + var calls: u32 = 0; + var result: bool = true; + fn call(_: *anyopaque) bool { + calls += 1; + return result; + } +}; + +test "request: first real call finishes once; repeats and null never reach the JNI half" { + const testing = @import("std").testing; + var flag = false; + var dummy: u8 = 0; + TestFinish.calls = 0; + TestFinish.result = true; + + try testing.expect(!request(&flag, null, TestFinish.call)); + try testing.expectEqual(@as(u32, 0), TestFinish.calls); + try testing.expect(!flag); // null does not take the guard + + try testing.expect(request(&flag, @ptrCast(&dummy), TestFinish.call)); + try testing.expectEqual(@as(u32, 1), TestFinish.calls); + try testing.expect(flag); + + try testing.expect(!request(&flag, @ptrCast(&dummy), TestFinish.call)); + try testing.expectEqual(@as(u32, 1), TestFinish.calls); // not called again +} + +test "request: a finish that did nothing releases the guard for a later call" { + const testing = @import("std").testing; + var flag = false; + var dummy: u8 = 0; + TestFinish.calls = 0; + TestFinish.result = false; + + try testing.expect(!request(&flag, @ptrCast(&dummy), TestFinish.call)); + try testing.expectEqual(@as(u32, 1), TestFinish.calls); + try testing.expect(!flag); + + TestFinish.result = true; + try testing.expect(request(&flag, @ptrCast(&dummy), TestFinish.call)); + try testing.expectEqual(@as(u32, 2), TestFinish.calls); + try testing.expect(flag); +} diff --git a/src/jni/activity_finish.c b/src/jni/activity_finish.c new file mode 100644 index 0000000..0785880 --- /dev/null +++ b/src/jni/activity_finish.c @@ -0,0 +1,51 @@ +// Finish the running NativeActivity and end the process once it is destroyed +// (moca-tecnologia/flying-platform-labelle#979). The Zig side, `activity.zig`, +// owns the once-guard and documents the calling contract and why it is safe. +// +// In C for the same reason as the other files here: +// owns the `ANativeActivity` / `ANativeActivityCallbacks` layout. Off Android +// this is an empty TU. +#ifdef __ANDROID__ + +#include +#include +#include +#include + +// The glue's own `onDestroy` (android_native_app_glue.c installs it in +// `ANativeActivity_onCreate`), saved when the hook is installed. +static void (*glue_on_destroy)(ANativeActivity *) = NULL; +// The activity whose `onDestroy` is hooked. Callbacks are per activity +// instance, so another instance in the same process (labelle-bgfx#143) keeps +// the glue's callback; the check below is belt and braces. +static ANativeActivity *finishing = NULL; + +// Runs on the UI thread. The glue's `onDestroy` posts APP_CMD_DESTROY and +// BLOCKS until `android_main` has returned (the backend's normal destroy +// teardown), so by the time it returns the game loop is gone. Then end the +// process: see `activity.zig` for why, and why `_exit` rather than `exit`. +static void on_destroy_then_exit(ANativeActivity *na) { + if (glue_on_destroy != NULL) glue_on_destroy(na); + if (na != finishing) return; + __android_log_write(ANDROID_LOG_INFO, "labelle", "android: activity finished after a quit request; ending the process"); + _exit(0); +} + +// Install the destroy hook, then ask for the finish. The caller (`activity.zig`) +// guarantees this runs at most once per process, on the glue's app thread, +// while the activity still has its window — the contract that orders these +// writes before the UI thread's read of `callbacks->onDestroy` (see +// `activity.zig`). Returns 1 when the finish was requested, 0 when the +// activity has no callbacks table (nothing was touched). +int labelle_android_finish_activity(void *activity_ptr) { + ANativeActivity *na = (ANativeActivity *)activity_ptr; + if (na == NULL || na->callbacks == NULL) return 0; + finishing = na; + glue_on_destroy = na->callbacks->onDestroy; + na->callbacks->onDestroy = on_destroy_then_exit; + __android_log_write(ANDROID_LOG_INFO, "labelle", "android: quit requested; finishing the activity"); + ANativeActivity_finish(na); + return 1; +} + +#endif diff --git a/src/root.zig b/src/root.zig index 03152f1..1007253 100644 --- a/src/root.zig +++ b/src/root.zig @@ -19,6 +19,8 @@ //! * `renderer` — resolve the renderer at launch and export //! `LABELLE_BGFX_RENDERER` (labelle-android#27, labelle-bgfx#172); //! `crash_guard` is its D11 Vulkan crash guard (labelle-android#28). +//! * `activity` — finish the running activity when the game quits, then +//! end the process (moca-tecnologia/flying-platform-labelle#979). //! //! Every file is reached from here by relative import inside this module. //! Consumers `@import("labelle_android")` and pass the running @@ -52,6 +54,7 @@ pub const video = @import("video.zig"); pub const immersive = @import("immersive.zig"); pub const renderer = @import("renderer.zig"); pub const crash_guard = @import("crash_guard.zig"); +pub const activity = @import("activity.zig"); test { // Explicit refs: a lazily-referenced file's tests are otherwise never @@ -65,4 +68,5 @@ test { _ = immersive; _ = renderer; _ = crash_guard; + _ = activity; }