diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d3ea12f..5a5aaf7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -66,7 +66,7 @@ jobs:
uses: nttld/setup-ndk@v1
with:
ndk-version: r27c
- # Compiles the three JNI C TUs against the NDK sysroot and the module's
+ # Compiles the JNI C TUs against the NDK sysroot and the module's
# `extern "c"` / AAudio / MediaCodec bindings for aarch64 (object
# emission; nothing runs).
- name: Cross-build for Android
diff --git a/build.zig b/build.zig
index aaf6106..32879f2 100644
--- a/build.zig
+++ b/build.zig
@@ -136,6 +136,7 @@ pub fn build(b: *std.Build) void {
"src/jni/intent_extras.c",
"src/jni/debuggable.c",
"src/jni/window_relayout.c",
+ "src/jni/renderer_query.c",
},
.flags = &.{ "-std=c11", "-Wall" },
});
diff --git a/src/crash_guard.zig b/src/crash_guard.zig
new file mode 100644
index 0000000..1e85509
--- /dev/null
+++ b/src/crash_guard.zig
@@ -0,0 +1,25 @@
+//! Vulkan crash guard (labelle-bgfx#172 D11): the SEAM only.
+//!
+//! D11: mark "Vulkan start in progress" in the app's internal storage before a
+//! Vulkan init, clear it once the game has run stably, and start on GLES at
+//! the next launch if the mark is still there. That work is
+//! labelle-toolkit/labelle-android#28. Until it lands this is a stub that
+//! never disables Vulkan, so `renderer.resolve` (#27) already consults the
+//! guard in the right place (after the intent override, before the provider
+//! setting) and #28 only has to replace the body below.
+const std = @import("std");
+
+/// True when a previous launch crashed during a Vulkan start and this launch
+/// must use GLES instead. `activity` is the running `ANativeActivity*`
+/// (opaque), for the internal-storage path #28 will need.
+///
+/// Stub: always "not disabled".
+pub fn isVulkanDisabled(activity: ?*const anyopaque) bool {
+ _ = activity;
+ return false;
+}
+
+test "stub: Vulkan is never disabled" {
+ try std.testing.expect(!isVulkanDisabled(null));
+ try std.testing.expect(!isVulkanDisabled(@ptrFromInt(0x1000)));
+}
diff --git a/src/intent_env.zig b/src/intent_env.zig
index 9ba77b5..27bbb11 100644
--- a/src/intent_env.zig
+++ b/src/intent_env.zig
@@ -39,8 +39,21 @@ pub const keys = [_]Key{
.{ .name = "LABELLE_PROFILE", .debuggable_only = false },
.{ .name = "LABELLE_SCREENSHOT_PATH", .debuggable_only = true },
.{ .name = "LABELLE_SCREENSHOT_AFTER_SEC", .debuggable_only = true },
+ // The renderer override (labelle-android#27, labelle-bgfx#172 D3): a
+ // test/debug channel, so debuggable builds only. `renderer.zig` decides
+ // the final `LABELLE_BGFX_RENDERER` after this runs (and re-applies the
+ // same gate and validates the value).
+ .{ .name = "LABELLE_BGFX_RENDERER", .debuggable_only = true },
};
+/// Index of `name` in `keys` (comptime: a typo is a compile error).
+pub fn indexOf(comptime name: []const u8) usize {
+ inline for (keys, 0..) |k, i| {
+ if (comptime std.mem.eql(u8, k.name, name)) return i;
+ }
+ @compileError("not an allow-listed intent key: " ++ name);
+}
+
/// Is `name` one of the keys copied from the intent?
pub fn isAllowed(name: []const u8) bool {
for (keys) |k| {
@@ -189,15 +202,17 @@ fn extrasWith(pairs: []const struct { [:0]const u8, [:0]const u8 }) [keys.len]?[
return out;
}
-test "the allow-list is exactly the four run-option keys" {
+test "the allow-list is exactly the four run-option keys plus the renderer override" {
try testing.expect(isAllowed("LABELLE_SCENE"));
try testing.expect(isAllowed("LABELLE_PROFILE"));
try testing.expect(isAllowed("LABELLE_SCREENSHOT_PATH"));
try testing.expect(isAllowed("LABELLE_SCREENSHOT_AFTER_SEC"));
- try testing.expectEqual(@as(usize, 4), keys.len);
+ try testing.expect(isAllowed("LABELLE_BGFX_RENDERER"));
+ try testing.expect(keys[indexOf("LABELLE_BGFX_RENDERER")].debuggable_only);
+ try testing.expectEqual(@as(usize, 5), keys.len);
// Other labelle knobs and arbitrary env names are not copied.
try testing.expect(!isAllowed("LABELLE_FIXED_DT"));
- try testing.expect(!isAllowed("LABELLE_BGFX_RENDERER"));
+ try testing.expect(!isAllowed("LABELLE_BGFX_BACKEND"));
try testing.expect(!isAllowed("LD_PRELOAD"));
try testing.expect(!isAllowed("PATH"));
// Exact match only: no prefix, case or whitespace slack.
@@ -248,6 +263,25 @@ test "screenshot extras need a debuggable apk; debuggable is asked once" {
try testing.expectEqual(@as(usize, 1), debug.debuggable_calls);
}
+test "the renderer override needs a debuggable apk and is reverted on a plain relaunch" {
+ const extras = extrasWith(&.{.{ "LABELLE_BGFX_RENDERER", "vulkan" }});
+
+ var state: State = .{};
+ var release: FakeEnv = .{ .is_debuggable = false };
+ apply(&state, extras, &release);
+ try testing.expect(release.get("LABELLE_BGFX_RENDERER") == null);
+ try testing.expectEqual(@as(usize, 1), release.debuggable_calls);
+
+ var state2: State = .{};
+ var debug: FakeEnv = .{ .is_debuggable = true };
+ apply(&state2, extras, &debug);
+ try testing.expectEqualStrings("vulkan", debug.get("LABELLE_BGFX_RENDERER").?);
+ try testing.expect(state2.set_by_intent[indexOf("LABELLE_BGFX_RENDERER")]);
+ apply(&state2, @splat(null), &debug);
+ try testing.expect(debug.get("LABELLE_BGFX_RENDERER") == null);
+ try testing.expect(!state2.set_by_intent[indexOf("LABELLE_BGFX_RENDERER")]);
+}
+
test "an empty extra is treated as absent" {
var state: State = .{};
var env: FakeEnv = .{};
diff --git a/src/jni/renderer_query.c b/src/jni/renderer_query.c
new file mode 100644
index 0000000..6fd9174
--- /dev/null
+++ b/src/jni/renderer_query.c
@@ -0,0 +1,158 @@
+// The two device/package questions the launch-time renderer resolution asks
+// (labelle-android#27, labelle-bgfx#172 D1-D3). The pure decision is
+// `renderer.zig`; this is the JNI half:
+//
+// * the provider setting the packager stamps into the manifest
+// (``,
+// labelle-android#26):
+// getPackageManager()
+// .getApplicationInfo(getPackageName(), GET_META_DATA)
+// .metaData.getString("labelle.renderer")
+// * `auto`'s device check:
+// getPackageManager().hasSystemFeature("android.hardware.vulkan.version",
+// 0x401000) // Vulkan 1.1
+//
+// In C for the same reason as intent_extras.c: already declares the
+// JNI vtables. Off Android this is an empty TU.
+#ifdef __ANDROID__
+
+#include
+#include
+#include
+#include
+
+// android.content.pm.PackageManager.GET_META_DATA (API 1).
+#define LABELLE_GET_META_DATA 0x00000080
+
+// intent_extras.c: attach-if-detached, reporting what we attached.
+JNIEnv *labelle_android_acquire_env(JavaVM *vm, int *we_attached);
+
+// `activity.getPackageManager()`, or NULL (possibly with an exception
+// pending; the callers clear it). A local ref inside the caller's frame.
+static jobject package_manager(JNIEnv *env, jobject activity) {
+ jclass activity_cls = (*env)->GetObjectClass(env, activity);
+ if (activity_cls == NULL) return NULL;
+ jmethodID get_pm = (*env)->GetMethodID(env, activity_cls, "getPackageManager", "()Landroid/content/pm/PackageManager;");
+ if (get_pm == NULL || (*env)->ExceptionCheck(env)) return NULL;
+ jobject pm = (*env)->CallObjectMethod(env, activity, get_pm);
+ if ((*env)->ExceptionCheck(env)) return NULL;
+ return pm;
+}
+
+// Copy the `labelle.renderer` meta-data string into `buf` (NUL-terminated).
+// Returns its length (>= 0), -1 when the key is absent (or not a string),
+// -2 on any JNI failure, -3 when the value does not fit in `buf_cap`.
+// Never leaves a Java exception pending and never leaks a local ref.
+// Callable from any thread (attached or not).
+int labelle_android_read_renderer_meta(const void *activity_ptr, char *buf, size_t buf_cap) {
+ const ANativeActivity *na = (const ANativeActivity *)activity_ptr;
+ if (na == NULL || na->vm == NULL || na->clazz == NULL || buf == NULL || buf_cap == 0) return -2;
+ JavaVM *vm = na->vm;
+ jobject activity = na->clazz;
+
+ int we_attached = 0;
+ JNIEnv *env = labelle_android_acquire_env(vm, &we_attached);
+ if (env == NULL) return -2;
+
+ int result = -2;
+ if ((*env)->PushLocalFrame(env, 16) == JNI_OK) {
+ jobject pm = package_manager(env, activity);
+ jclass activity_cls = (pm != NULL) ? (*env)->GetObjectClass(env, activity) : NULL;
+ jmethodID get_pkg = activity_cls ? (*env)->GetMethodID(env, activity_cls, "getPackageName", "()Ljava/lang/String;") : NULL;
+ jstring pkg = (get_pkg && !(*env)->ExceptionCheck(env)) ? (jstring)(*env)->CallObjectMethod(env, activity, get_pkg) : NULL;
+ jclass pm_cls = (pkg && !(*env)->ExceptionCheck(env)) ? (*env)->GetObjectClass(env, pm) : NULL;
+ // The (String, int) overload: deprecated from API 33 in favour of
+ // the ApplicationInfoFlags one, but still present and working.
+ jmethodID get_app_info = pm_cls ? (*env)->GetMethodID(env, pm_cls, "getApplicationInfo", "(Ljava/lang/String;I)Landroid/content/pm/ApplicationInfo;") : NULL;
+ // Throws NameNotFoundException only if our own package vanished.
+ jobject app_info = (get_app_info && !(*env)->ExceptionCheck(env)) ? (*env)->CallObjectMethod(env, pm, get_app_info, pkg, (jint)LABELLE_GET_META_DATA) : NULL;
+ if (app_info != NULL && !(*env)->ExceptionCheck(env)) {
+ jclass app_info_cls = (*env)->GetObjectClass(env, app_info);
+ jfieldID meta_fid = app_info_cls ? (*env)->GetFieldID(env, app_info_cls, "metaData", "Landroid/os/Bundle;") : NULL;
+ if (meta_fid != NULL && !(*env)->ExceptionCheck(env)) {
+ jobject bundle = (*env)->GetObjectField(env, app_info, meta_fid);
+ if (bundle == NULL) {
+ // No at all in .
+ result = -1;
+ } else {
+ jclass bundle_cls = (*env)->GetObjectClass(env, bundle);
+ jmethodID get_string = bundle_cls ? (*env)->GetMethodID(env, bundle_cls, "getString", "(Ljava/lang/String;)Ljava/lang/String;") : NULL;
+ jstring jkey = (get_string && !(*env)->ExceptionCheck(env)) ? (*env)->NewStringUTF(env, "labelle.renderer") : NULL;
+ jstring jval = (jkey && !(*env)->ExceptionCheck(env)) ? (jstring)(*env)->CallObjectMethod(env, bundle, get_string, jkey) : NULL;
+ if (jkey != NULL && !(*env)->ExceptionCheck(env)) {
+ if (jval == NULL) {
+ // Missing key, or a non-string value.
+ result = -1;
+ } else {
+ // The accepted values are ASCII, so modified
+ // UTF-8 is byte-identical for every valid one.
+ jsize chars = (*env)->GetStringLength(env, jval);
+ jsize bytes = (*env)->GetStringUTFLength(env, jval);
+ if (bytes < 0 || (size_t)bytes + 1 > buf_cap) {
+ result = -3;
+ } else {
+ (*env)->GetStringUTFRegion(env, jval, 0, chars, buf);
+ if (!(*env)->ExceptionCheck(env)) {
+ buf[bytes] = 0;
+ result = (int)bytes;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ // Any JNI call above may have raised; clear before popping so we
+ // never hand a pending exception back to the caller's thread.
+ if ((*env)->ExceptionCheck(env)) {
+ (*env)->ExceptionClear(env);
+ result = -2;
+ }
+ (*env)->PopLocalFrame(env, NULL);
+ } else if ((*env)->ExceptionCheck(env)) {
+ (*env)->ExceptionClear(env);
+ __android_log_print(ANDROID_LOG_WARN, "labelle-android", "renderer meta-data: PushLocalFrame failed; exception cleared");
+ }
+
+ if (we_attached) (*vm)->DetachCurrentThread(vm);
+ return result;
+}
+
+// `PackageManager.hasSystemFeature(name, version)` (API 24+). Returns 1 when
+// the device reports the feature at `version` or above, 0 otherwise — and 0
+// on any JNI failure: `auto` then falls back to GLES, the safe choice.
+int labelle_android_has_system_feature(const void *activity_ptr, const char *name, int version) {
+ const ANativeActivity *na = (const ANativeActivity *)activity_ptr;
+ if (na == NULL || na->vm == NULL || na->clazz == NULL || name == NULL) return 0;
+ JavaVM *vm = na->vm;
+ jobject activity = na->clazz;
+
+ int we_attached = 0;
+ JNIEnv *env = labelle_android_acquire_env(vm, &we_attached);
+ if (env == NULL) return 0;
+
+ int has = 0;
+ if ((*env)->PushLocalFrame(env, 8) == JNI_OK) {
+ jobject pm = package_manager(env, activity);
+ jclass pm_cls = (pm && !(*env)->ExceptionCheck(env)) ? (*env)->GetObjectClass(env, pm) : NULL;
+ jmethodID has_feature = pm_cls ? (*env)->GetMethodID(env, pm_cls, "hasSystemFeature", "(Ljava/lang/String;I)Z") : NULL;
+ jstring jname = (has_feature && !(*env)->ExceptionCheck(env)) ? (*env)->NewStringUTF(env, name) : NULL;
+ if (jname != NULL && !(*env)->ExceptionCheck(env)) {
+ jboolean r = (*env)->CallBooleanMethod(env, pm, has_feature, jname, (jint)version);
+ if (!(*env)->ExceptionCheck(env)) has = r ? 1 : 0;
+ }
+ if ((*env)->ExceptionCheck(env)) {
+ (*env)->ExceptionClear(env);
+ has = 0;
+ }
+ (*env)->PopLocalFrame(env, NULL);
+ } else if ((*env)->ExceptionCheck(env)) {
+ (*env)->ExceptionClear(env);
+ __android_log_print(ANDROID_LOG_WARN, "labelle-android", "system feature: PushLocalFrame failed; exception cleared");
+ }
+
+ if (we_attached) (*vm)->DetachCurrentThread(vm);
+ return has;
+}
+
+#endif /* __ANDROID__ */
diff --git a/src/launch_intent.zig b/src/launch_intent.zig
index 4670089..c71276e 100644
--- a/src/launch_intent.zig
+++ b/src/launch_intent.zig
@@ -26,10 +26,16 @@
//! which only that loop delivers, so the env is complete first.
//! * sokol: from the top of the generated `sokol_main()` on the UI thread
//! (already attached), before the render thread that runs `init` exists.
+//!
+//! `apply` ends with `renderer.resolve` (labelle-android#27), which decides
+//! and exports `LABELLE_BGFX_RENDERER`; it runs last so its value is final
+//! (it overrides whatever the raw intent extra set) and, like the extras, it
+//! lands before bgfx is initialised on the first INIT_WINDOW.
const std = @import("std");
const intent_env = @import("intent_env.zig");
const debuggable_mod = @import("debuggable.zig");
+const renderer = @import("renderer.zig");
const is_android = @import("root.zig").is_android;
// `jni/intent_extras.c` — only referenced on Android, where build.zig
@@ -90,6 +96,7 @@ pub fn apply(activity: ?*const anyopaque) void {
// Still revert what an earlier launch's intent set in this process
// (all-absent extras only ever revert/keep, never set).
intent_env.apply(&state, @splat(null), LibcEnv{ .activity = a });
+ renderer.resolve(a, null);
return;
}
var extras: [intent_env.keys.len]?[:0]const u8 = @splat(null);
@@ -102,6 +109,9 @@ pub fn apply(activity: ?*const anyopaque) void {
off += n + 1;
}
intent_env.apply(&state, extras, LibcEnv{ .activity = a });
+ // LAST: the renderer decision (intent override → crash guard → provider
+ // setting → auto) overwrites the raw extra with the validated value.
+ renderer.resolve(a, extras[intent_env.indexOf(renderer.env_name)]);
}
test "apply is a no-op off Android (and never touches the externs)" {
diff --git a/src/renderer.zig b/src/renderer.zig
new file mode 100644
index 0000000..8916edd
--- /dev/null
+++ b/src/renderer.zig
@@ -0,0 +1,333 @@
+//! Launch-time renderer resolution (labelle-android#27; labelle-bgfx#172
+//! D1-D3, D11).
+//!
+//! labelle-android owns the Android renderer POLICY; labelle-bgfx only reads
+//! `LABELLE_BGFX_RENDERER` before `bgfx.init` (labelle-bgfx#176). This file
+//! decides the value and exports it, once per activity launch.
+//!
+//! Resolution order, first match wins:
+//! 1. `intent`: the `LABELLE_BGFX_RENDERER` launch extra (`vulkan`|`gles`),
+//! only in a debuggable apk (the same gate as the other debuggable-only
+//! keys in `intent_env.zig`). Anything else is warned about and ignored.
+//! 2. `crash-guard`: Vulkan disabled after a crashed Vulkan start (D11,
+//! `crash_guard.zig`; a stub until labelle-android#28) → `gles`.
+//! 3. `setting`: the provider setting, stamped into the manifest as
+//! ``
+//! (labelle-android#26). Missing or unreadable → `gles`; an invalid
+//! value → `gles` with a warning.
+//! 4. `auto` (the setting's third value): Vulkan when the device reports
+//! `android.hardware.vulkan.version` >= 1.1 (0x401000), else `gles`.
+//!
+//! Then `setenv("LABELLE_BGFX_RENDERER", "vulkan"|"gles", 1)` and one log
+//! line: `renderer: (source: intent|crash-guard|setting|auto)`.
+//!
+//! ## Where it runs
+//!
+//! `launch_intent.apply` calls `resolve` LAST, so it runs from the same entry
+//! point the backends already call before anything reads the environment:
+//! bgfx calls `apply` at the top of `run(app)`, before the event loop that
+//! delivers the first INIT_WINDOW, and bgfx is only initialised inside that
+//! loop, so the variable is always set before `bgfx.init`.
+//!
+//! `decide` is the PURE half (host-tested with fakes below); `resolve` is the
+//! Android glue (JNI in `jni/renderer_query.c`, libc `setenv`).
+const std = @import("std");
+const debuggable_mod = @import("debuggable.zig");
+const crash_guard = @import("crash_guard.zig");
+const is_android = @import("root.zig").is_android;
+
+/// The environment variable labelle-bgfx reads before `bgfx.init`.
+pub const env_name: [:0]const u8 = "LABELLE_BGFX_RENDERER";
+/// The manifest `` name the packager stamps (labelle-android#26).
+pub const meta_data_name = "labelle.renderer";
+/// `android.hardware.vulkan.version` for Vulkan 1.1 (D2, D7).
+pub const vulkan_1_1: c_int = 0x401000;
+
+/// What is exported in `LABELLE_BGFX_RENDERER`.
+pub const Renderer = enum {
+ gles,
+ vulkan,
+
+ pub fn envValue(self: Renderer) [:0]const u8 {
+ return switch (self) {
+ .gles => "gles",
+ .vulkan => "vulkan",
+ };
+ }
+};
+
+/// Which rule decided (the `source:` in the log line).
+pub const Source = enum {
+ intent,
+ crash_guard,
+ setting,
+ auto,
+
+ pub fn label(self: Source) []const u8 {
+ return switch (self) {
+ .intent => "intent",
+ .crash_guard => "crash-guard",
+ .setting => "setting",
+ .auto => "auto",
+ };
+ }
+};
+
+pub const Decision = struct { renderer: Renderer, source: Source };
+
+/// The provider setting's values.
+pub const Setting = enum { gles, vulkan, auto };
+
+/// An explicit renderer (the intent extra). Exact, lower-case match only.
+pub fn parseRenderer(value: []const u8) ?Renderer {
+ if (std.mem.eql(u8, value, "vulkan")) return .vulkan;
+ if (std.mem.eql(u8, value, "gles")) return .gles;
+ return null;
+}
+
+/// The `labelle.renderer` meta-data. Exact, lower-case match only (the
+/// provider setting is parsed strictly, so a valid APK only carries these).
+pub fn parseSetting(value: []const u8) ?Setting {
+ if (std.mem.eql(u8, value, "auto")) return .auto;
+ if (parseRenderer(value)) |r| return switch (r) {
+ .gles => .gles,
+ .vulkan => .vulkan,
+ };
+ return null;
+}
+
+/// Decide the renderer. `q` answers, lazily and in this order, only what the
+/// rules need:
+/// * `intentExtra() ?[]const u8` — the launch extra (null = absent)
+/// * `debuggable() bool` — asked only when the extra is present
+/// * `vulkanDisabled() bool` — the D11 crash guard
+/// * `metaData() ?[]const u8` — the `labelle.renderer` meta-data (null =
+/// missing or unreadable)
+/// * `hasVulkan() bool` — asked only for `auto`
+pub fn decide(q: anytype) Decision {
+ if (q.intentExtra()) |v| {
+ // An empty extra counts as absent, as it does in `intent_env`.
+ if (v.len > 0) {
+ if (!q.debuggable()) {
+ std.log.info("android: ignoring intent extra {s}: the apk is not debuggable", .{env_name});
+ } else if (parseRenderer(v)) |r| {
+ return .{ .renderer = r, .source = .intent };
+ } else {
+ std.log.warn("android: ignoring intent extra {s}='{s}' (expected 'vulkan' or 'gles')", .{ env_name, v });
+ }
+ }
+ }
+ if (q.vulkanDisabled()) return .{ .renderer = .gles, .source = .crash_guard };
+ const setting: Setting = if (q.metaData()) |raw|
+ parseSetting(raw) orelse blk: {
+ std.log.warn("android: invalid {s} meta-data '{s}' (expected 'gles', 'vulkan' or 'auto'); using gles", .{ meta_data_name, raw });
+ break :blk .gles;
+ }
+ else
+ .gles;
+ return switch (setting) {
+ .gles => .{ .renderer = .gles, .source = .setting },
+ .vulkan => .{ .renderer = .vulkan, .source = .setting },
+ .auto => .{ .renderer = if (q.hasVulkan()) .vulkan else .gles, .source = .auto },
+ };
+}
+
+// ── Android glue ────────────────────────────────────────────────────────
+
+// `jni/renderer_query.c` — only referenced on Android, where build.zig
+// compiles the C TU into this module.
+extern "c" fn labelle_android_read_renderer_meta(activity: ?*const anyopaque, buf: [*]u8, buf_cap: usize) c_int;
+extern "c" fn labelle_android_has_system_feature(activity: ?*const anyopaque, name: [*:0]const u8, version: c_int) c_int;
+extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
+
+const JniQuery = struct {
+ activity: *const anyopaque,
+ extra: ?[:0]const u8,
+ meta_buf: [64]u8 = undefined,
+
+ pub fn intentExtra(self: *JniQuery) ?[]const u8 {
+ return self.extra;
+ }
+ pub fn debuggable(self: *JniQuery) bool {
+ return debuggable_mod.isDebuggable(self.activity);
+ }
+ pub fn vulkanDisabled(self: *JniQuery) bool {
+ return crash_guard.isVulkanDisabled(self.activity);
+ }
+ pub fn metaData(self: *JniQuery) ?[]const u8 {
+ const n = labelle_android_read_renderer_meta(self.activity, &self.meta_buf, self.meta_buf.len);
+ switch (n) {
+ -1 => {
+ std.log.info("android: no {s} meta-data; using gles", .{meta_data_name});
+ return null;
+ },
+ -3 => {
+ std.log.warn("android: {s} meta-data too long; using gles", .{meta_data_name});
+ return null;
+ },
+ else => if (n < 0) {
+ std.log.warn("android: could not read the {s} meta-data; using gles", .{meta_data_name});
+ return null;
+ },
+ }
+ return self.meta_buf[0..@intCast(n)];
+ }
+ pub fn hasVulkan(self: *JniQuery) bool {
+ return labelle_android_has_system_feature(self.activity, "android.hardware.vulkan.version", vulkan_1_1) != 0;
+ }
+};
+
+/// Resolve the renderer and export `LABELLE_BGFX_RENDERER`. `activity` is the
+/// running `ANativeActivity*` (opaque); `intent_extra` is this launch's
+/// `LABELLE_BGFX_RENDERER` extra (null = absent). Comptime no-op off
+/// Android; a null activity changes nothing. Called by `launch_intent.apply`,
+/// after the intent extras are applied, so the value it writes is final.
+pub fn resolve(activity: ?*const anyopaque, intent_extra: ?[:0]const u8) void {
+ if (comptime !is_android) return;
+ const a = activity orelse return;
+ var q: JniQuery = .{ .activity = a, .extra = intent_extra };
+ const d = decide(&q);
+ if (setenv(env_name.ptr, d.renderer.envValue().ptr, 1) != 0) {
+ std.log.warn("android: could not set {s}={s}", .{ env_name, d.renderer.envValue() });
+ }
+ std.log.info("renderer: {s} (source: {s})", .{ d.renderer.envValue(), d.source.label() });
+}
+
+// ── Tests ───────────────────────────────────────────────────────────────
+
+const testing = std.testing;
+
+/// Scripted answers + a record of which questions were asked, so each test
+/// asserts WHICH rule decided, not just the value.
+const Fake = struct {
+ extra: ?[]const u8 = null,
+ is_debuggable: bool = false,
+ disabled: bool = false,
+ meta: ?[]const u8 = null,
+ vulkan: bool = false,
+
+ debuggable_calls: usize = 0,
+ guard_calls: usize = 0,
+ meta_calls: usize = 0,
+ vulkan_calls: usize = 0,
+
+ pub fn intentExtra(self: *Fake) ?[]const u8 {
+ return self.extra;
+ }
+ pub fn debuggable(self: *Fake) bool {
+ self.debuggable_calls += 1;
+ return self.is_debuggable;
+ }
+ pub fn vulkanDisabled(self: *Fake) bool {
+ self.guard_calls += 1;
+ return self.disabled;
+ }
+ pub fn metaData(self: *Fake) ?[]const u8 {
+ self.meta_calls += 1;
+ return self.meta;
+ }
+ pub fn hasVulkan(self: *Fake) bool {
+ self.vulkan_calls += 1;
+ return self.vulkan;
+ }
+};
+
+fn expectDecision(expected: Decision, got: Decision) !void {
+ try testing.expectEqual(expected.renderer, got.renderer);
+ try testing.expectEqual(expected.source, got.source);
+}
+
+test "intent override wins in a debuggable apk; nothing later is asked" {
+ var f: Fake = .{ .extra = "vulkan", .is_debuggable = true, .disabled = true, .meta = "gles" };
+ try expectDecision(.{ .renderer = .vulkan, .source = .intent }, decide(&f));
+ try testing.expectEqual(@as(usize, 1), f.debuggable_calls);
+ try testing.expectEqual(@as(usize, 0), f.guard_calls);
+ try testing.expectEqual(@as(usize, 0), f.meta_calls);
+
+ var g: Fake = .{ .extra = "gles", .is_debuggable = true, .meta = "vulkan" };
+ try expectDecision(.{ .renderer = .gles, .source = .intent }, decide(&g));
+ try testing.expectEqual(@as(usize, 0), g.meta_calls);
+}
+
+test "the intent override is ignored when the apk is not debuggable" {
+ var f: Fake = .{ .extra = "vulkan", .is_debuggable = false, .meta = "gles" };
+ try expectDecision(.{ .renderer = .gles, .source = .setting }, decide(&f));
+ try testing.expectEqual(@as(usize, 1), f.debuggable_calls);
+ try testing.expectEqual(@as(usize, 1), f.meta_calls);
+}
+
+test "an absent or empty extra never asks debuggable" {
+ var f: Fake = .{ .meta = "vulkan" };
+ try expectDecision(.{ .renderer = .vulkan, .source = .setting }, decide(&f));
+ var g: Fake = .{ .extra = "", .is_debuggable = true, .meta = "vulkan" };
+ try expectDecision(.{ .renderer = .vulkan, .source = .setting }, decide(&g));
+ try testing.expectEqual(@as(usize, 0), f.debuggable_calls + g.debuggable_calls);
+}
+
+test "an invalid intent value falls through to the next rule" {
+ inline for (.{ "metal", "Vulkan", "opengl", "auto", " vulkan" }) |bad| {
+ var f: Fake = .{ .extra = bad, .is_debuggable = true, .meta = "vulkan" };
+ try expectDecision(.{ .renderer = .vulkan, .source = .setting }, decide(&f));
+ try testing.expectEqual(@as(usize, 1), f.meta_calls);
+ }
+}
+
+test "the crash guard comes after the intent and before the setting" {
+ var f: Fake = .{ .disabled = true, .meta = "vulkan", .vulkan = true };
+ try expectDecision(.{ .renderer = .gles, .source = .crash_guard }, decide(&f));
+ try testing.expectEqual(@as(usize, 1), f.guard_calls);
+ try testing.expectEqual(@as(usize, 0), f.meta_calls);
+ try testing.expectEqual(@as(usize, 0), f.vulkan_calls);
+
+ // A non-debuggable extra does not skip the guard either.
+ var g: Fake = .{ .extra = "vulkan", .disabled = true, .meta = "vulkan" };
+ try expectDecision(.{ .renderer = .gles, .source = .crash_guard }, decide(&g));
+}
+
+test "the setting applies when there is no override and the guard is clear" {
+ var f: Fake = .{ .meta = "vulkan" };
+ try expectDecision(.{ .renderer = .vulkan, .source = .setting }, decide(&f));
+ try testing.expectEqual(@as(usize, 1), f.guard_calls);
+ try testing.expectEqual(@as(usize, 0), f.vulkan_calls); // explicit: no device check
+ var g: Fake = .{ .meta = "gles", .vulkan = true };
+ try expectDecision(.{ .renderer = .gles, .source = .setting }, decide(&g));
+}
+
+test "missing meta-data means gles" {
+ var f: Fake = .{ .meta = null, .vulkan = true };
+ try expectDecision(.{ .renderer = .gles, .source = .setting }, decide(&f));
+ try testing.expectEqual(@as(usize, 1), f.meta_calls);
+ try testing.expectEqual(@as(usize, 0), f.vulkan_calls);
+}
+
+test "an invalid meta-data value means gles" {
+ inline for (.{ "metal", "VULKAN", "Auto", "", "vulkan " }) |bad| {
+ var f: Fake = .{ .meta = bad, .vulkan = true };
+ try expectDecision(.{ .renderer = .gles, .source = .setting }, decide(&f));
+ try testing.expectEqual(@as(usize, 0), f.vulkan_calls);
+ }
+}
+
+test "auto: Vulkan when the device has Vulkan 1.1, else gles" {
+ var f: Fake = .{ .meta = "auto", .vulkan = true };
+ try expectDecision(.{ .renderer = .vulkan, .source = .auto }, decide(&f));
+ try testing.expectEqual(@as(usize, 1), f.vulkan_calls);
+ var g: Fake = .{ .meta = "auto", .vulkan = false };
+ try expectDecision(.{ .renderer = .gles, .source = .auto }, decide(&g));
+ try testing.expectEqual(@as(usize, 1), g.vulkan_calls);
+}
+
+test "env values and log labels" {
+ try testing.expectEqualStrings("vulkan", Renderer.vulkan.envValue());
+ try testing.expectEqualStrings("gles", Renderer.gles.envValue());
+ try testing.expectEqualStrings("crash-guard", Source.crash_guard.label());
+ try testing.expectEqualStrings("intent", Source.intent.label());
+ try testing.expectEqualStrings("setting", Source.setting.label());
+ try testing.expectEqualStrings("auto", Source.auto.label());
+}
+
+test "resolve is a no-op off Android (and never touches the externs)" {
+ if (comptime is_android) return error.SkipZigTest;
+ resolve(null, null);
+ resolve(@ptrFromInt(0x1000), "vulkan");
+}
diff --git a/src/root.zig b/src/root.zig
index 38bf8ea..3238c03 100644
--- a/src/root.zig
+++ b/src/root.zig
@@ -16,6 +16,9 @@
//! * `video` — the MediaCodec H.264 decoder + audio-track decode
//! (`VideoDecoder`, `decodeTrack`) and the pure `yuv`/`planes` helpers the
//! desktop decoders share (FP#549; links `libmediandk`).
+//! * `renderer` — resolve the renderer at launch and export
+//! `LABELLE_BGFX_RENDERER` (labelle-android#27, labelle-bgfx#172);
+//! `crash_guard` is its D11 seam (stub until labelle-android#28).
//!
//! Every file is reached from here by relative import inside this module.
//! Consumers `@import("labelle_android")` and pass the running
@@ -47,6 +50,8 @@ pub const relayout = @import("relayout.zig");
pub const aaudio = @import("aaudio.zig");
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");
test {
// Explicit refs: a lazily-referenced file's tests are otherwise never
@@ -58,4 +63,6 @@ test {
_ = aaudio;
_ = video;
_ = immersive;
+ _ = renderer;
+ _ = crash_guard;
}