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; }