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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
});
Expand Down
154 changes: 154 additions & 0 deletions src/activity.zig
Original file line number Diff line number Diff line change
@@ -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);
}
51 changes: 51 additions & 0 deletions src/jni/activity_finish.c
Original file line number Diff line number Diff line change
@@ -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: <android/native_activity.h>
// owns the `ANativeActivity` / `ANativeActivityCallbacks` layout. Off Android
// this is an empty TU.
#ifdef __ANDROID__

#include <android/log.h>
#include <android/native_activity.h>
#include <stddef.h>
#include <unistd.h>

// 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;
Comment thread
apotema marked this conversation as resolved.
__android_log_write(ANDROID_LOG_INFO, "labelle", "android: quit requested; finishing the activity");
ANativeActivity_finish(na);
return 1;
}

#endif
4 changes: 4 additions & 0 deletions src/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -65,4 +68,5 @@ test {
_ = immersive;
_ = renderer;
_ = crash_guard;
_ = activity;
}
Loading