feat(bgfx): Android codegen + .so link + APK, runs on-device (phase 4, #303) - #309
Conversation
#303) Phase 4 of bgfx-on-Android. The assembler now generates a bgfx+android build that compiles the game as a NativeActivity libgame.so for aarch64-linux-android, packages it into a signed APK, and runs on-device with bgfx initializing a real EGL/GLES renderer. Codegen (assembler): - backend_bgfx_android + android_link_bgfx + android_exe_app_import template sections; build_files.zig dispatches bgfx+android to them (fetch backend for android_target, pull the android_app glue module as backend_app, build a dynamic lib, link bgfx artifact + android/log/ EGL/GLESv3/m/dl, NDK libc.txt). Desktop bgfx keeps the glfw exe path. - backends/bgfx/templates/android.txt: the generated main.zig owns android_main, registering a one-shot engine-init callback (fires once bgfx is live on INIT_WINDOW) + a per-frame tick with the bgfx shell, then runs the loop. main_template.zig routes bgfx+android through the callback lifecycle (module-scope g/runner, void-safe init_code). Shell (backends/bgfx/src/android_app.zig): - setInitCallback: one-shot surface-ready hook fired after window.initWindow on the first INIT_WINDOW (engine/scene init), guarded against resume. - android_main export gated on !game_owns_main (read from root) so the generated game owns the C entry without a duplicate symbol. - sokol-compat: getNativeActivity() backs a root-module export of sapp_android_get_native_activity so the engine's immersive-mode accessor resolves with no sokol in the graph; the template also stubs labelle_android_gamepad_init/_shutdown (gamepad inert on bgfx-Android). Example: examples/bgfx-android (project + manifest + package_apk.sh). On-device (tablet R9XR6009TJN): installs, libgame.so dlopens, native_app_glue drives the lifecycle through INIT_WINDOW, and bgfx logs 'BGFX Init complete.' + EGL GLContext on the Adreno GPU. No struct-layout crash — the phase-3 hand-declared struct android_app reads correctly (input queue, native window, content rect). Clean shutdown on TERM_WINDOW. Tests: bgfx build.zig + main.zig codegen assertions; backend test + desktop example + assembler test/build all green.
PR SummaryMedium Risk Overview Codegen & build: For Backend shell: Sokol-compat shims in generated Android Example & tests: New Reviewed by Cursor Bugbot for commit 17d2491. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Code Review
This pull request implements Phase 4 of the bgfx-on-Android bring-up, enabling the bgfx backend to run on Android as a NativeActivity app. It introduces the necessary build configuration, template files, and a sample project, while adapting the code generation to support a callback-driven lifecycle for Android. The review feedback highlights two main improvements: using a comptime if expression in android_app.zig to safely check for the labelle_provides_android_main declaration without causing compile-time errors, and dynamically calculating the frame delta time (dt) in the Android template based on the configured target_fps instead of hardcoding it to 0.016.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| const game_owns_main = @hasDecl(root, "labelle_provides_android_main") and | ||
| root.labelle_provides_android_main; |
There was a problem hiding this comment.
Using the and operator to conditionally access a declaration from root can cause a compile-time error if the declaration does not exist, because the compiler still performs semantic analysis on the right-hand side of the expression. To ensure safe conditional compilation when the declaration is absent (such as during standalone backend compile-checks), use a comptime if expression instead.
const game_owns_main = if (@hasDecl(root, "labelle_provides_android_main"))\n root.labelle_provides_android_main\nelse\n false;
| // the activity is resumed, so no `isRunning()`/close handling is needed | ||
| // here — the activity lifecycle drives shutdown. | ||
| fn gameFrame() callconv(.c) void { | ||
| const dt: f32 = 0.016; |
There was a problem hiding this comment.
The frame delta time (dt) is currently hardcoded to 0.016 (60 FPS), which ignores the configured target_fps and can cause the game simulation speed to run incorrectly on high-refresh-rate screens (e.g., 120Hz) or when a different target FPS is configured. Calculate dt dynamically based on target_fps while clamping the denominator to at least 1 to prevent division-by-zero errors.
const dt: f32 = 1.0 / @as(f32, @floatFromInt(@max(target_fps, 1)));
…y decl guard (review) Phase-4 bot review: - Gfx screen size not synced (Cursor, medium): gameInit set the engine's screen height from the COMPILE-TIME config (e.g. 600) while the shell brought bgfx up at the device's native ANativeWindow size (e.g. 1200), mis-mapping gizmo/UI coordinates. Added window.getScreenWidth/getScreenHeight (return the real post-initWindow size) and the generated Android gameInit now feeds window.getScreenHeight() to g.setScreenHeight. - game_owns_main @hasDecl guard (Gemini, high): the bare `@hasDecl(...) and root.decl` already compiles (Zig lazily short-circuits `and` at comptime, as CI confirms), but rewrote it as explicit if/else to remove any reader doubt. Declined: hardcoded dt=0.016 (Gemini) — that's the toolkit-wide convention for generated mains (raylib/sokol desktop + preview fixtures all use it); changing it only for bgfx-Android would diverge from every other backend. Real-dt is a separate, global decision. Verified: backend android compile-check + assembler build/test green; regen + rebuild of the bgfx-android .so confirms window.getScreenHeight() lands and links.
|
Bot triage — addressed in the latest commit:
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 56f1da8. Configure here.
There was a problem hiding this comment.
Pull request overview
This PR completes the “bgfx-on-Android” bring-up by extending the assembler’s codegen to support an Android NativeActivity build for the bgfx backend, including .so linking and APK packaging, and adds an end-to-end example project plus tests to lock the behavior in.
Changes:
- Add Android-specific bgfx build.zig template sections and wire backend/platform dispatch so bgfx+android generates a NativeActivity shared library and links required NDK/system libs.
- Extend
main.zigcodegen to use a callback-driven lifecycle for bgfx on Android (game-ownedandroid_main, init/tick callbacks, shell-owned loop). - Add
examples/bgfx-android(manifest + packaging script + sample scene) and new unit tests asserting the generated output shape.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/main_zig_tests.zig | Adds assertion coverage for bgfx+android callback-driven main.zig generation. |
| test/helpers.zig | Introduces a trimmed bgfx-android lifecycle template string for codegen tests. |
| test/build_zig_tests.zig | Adds assertion coverage for bgfx+android shared-library build + APK packaging wiring. |
| src/templates/build_zig.txt | Adds backend_bgfx_android, android_exe_app_import, and android_link_bgfx template sections. |
| src/codegen/main_template.zig | Routes bgfx+android through the callback-lifecycle path and renders the Android-specific lifecycle template. |
| src/build_files.zig | Wires bgfx+android dispatch to new template sections and adds backend_app import + backend-specific Android link section selection. |
| examples/bgfx-android/* | Adds an Android NativeActivity example project, packaging script, manifest, and sample scene. |
| backends/bgfx/templates/android.txt | Adds a new bgfx Android lifecycle template implementing game-owned android_main + init/tick callbacks. |
| backends/bgfx/src/window.zig | Exposes getScreenWidth/Height() so generated Android entry can sync engine coordinates to actual surface size. |
| backends/bgfx/src/android_app.zig | Adds root-controlled android_main export gating, one-shot init callback support, and native activity access plumbing. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| pub fn getNativeActivity() ?*anyopaque { | ||
| return @ptrCast(native_activity); | ||
| } |
| // generated build compiles the game as a `lib<name>.so` for | ||
| // `aarch64-linux-android`, importing the bgfx backend's Android-capable | ||
| // gfx/input/audio/window modules plus the `android_app` NativeActivity | ||
| // glue, and links the NDK libs (android, log, EGL, GLESv3, c++_shared). | ||
| // |
… comment (review) Second-round phase-4 review: - Screen metrics stale after resume (Cursor, medium): the surface can be destroyed + recreated at a different size (rotation, pause/resume) without gameInit re-running, so the once-at-init screen-height sync went stale. Now also refreshed every frame in gameFrame (cheap field write), matching the sokol Android template's per-frame dimension refresh. - c++_shared comment (Copilot): the example project.labelle claimed the link pulls libc++_shared.so; the bgfx Android link path doesn't (C++ runtime comes from Zig's toolchain). Corrected the comment. Declined: getNativeActivity @ptrCast (Copilot) — @ptrCast handles optional → optional pointers in Zig 0.16 (it compiles, CI green, and the activity resolves correctly on-device), so no unwrap is needed. Verified: assembler codegen tests green; regen + rebuild of the bgfx-android .so links with the per-frame height sync.
|
Second-round bot triage — addressed in the latest commit:
Verified: assembler codegen tests green; regenerated bgfx-android |
Backend work since v0.39.1: - wgpu: macOS Metal surface + textured sprite rendering (#290, #291) - bgfx: macOS bring-up to on-device Android (#296 epic, #304/#305/#307/#308/#309) - #310 AndroidBackendContext adapters: sokol (#312) + bgfx (#313) register the core seam; bgfx-Android gamepad via the shared android_gamepad sub-package - bgfx desktop gamepad: GLFW (#315) + SDL HIDAPI / Switch-pad support (#318) - cached bgfx CI job (#295) Android codegen now calls core.registerAndroidBackend → requires labelle-core >= v1.17.0 + labelle-engine >= v1.50.0.

Phase 4 (final) of the bgfx-on-Android bring-up. Closes #303. Completes #296. Builds on phases 1–3 + the device-less audio module (#306).
What
The assembler now generates a bgfx + Android build that produces a NativeActivity APK, and bgfx runs on-device. Three milestones, all verified — including independent on-device reproduction.
Milestone A —
backend_bgfx_androidcodegen +.solinksrc/templates/build_zig.txt: newbackend_bgfx_android,android_link_bgfx,android_exe_app_importsections;src/build_files.zigwires the.bgfx+.androiddispatch. The generatedbuild.zigfetches the bgfx backend foraarch64-linux-android(gfx/input/audio/window, no zglfw), pulls theandroid_appglue module, builds a dynamic lib, and linksbgfx+android/log/EGL/GLESv3/m/dl+ NDK libc.main.zig(src/codegen/main_template.zig+ newbackends/bgfx/templates/android.txt) owns theandroid_mainentry: registers a one-shot engine-init callback + per-frame tick with the bgfx shell, then runs the loop.backends/bgfx/src/android_app.ziggainssetInitCallback(fires afterwindow.initWindowon firstINIT_WINDOW) and gates its ownandroid_mainexport on!game_owns_main. Desktop bgfx is untouched (keeps the glfw exe loop).main.zigshimssapp_android_get_native_activity(forwards to the bgfx shell's stored activity) andlabelle_android_gamepad_init/shutdown(inert for now). Filed as a follow-up to abstract this properly.Milestone B — APK packaging
examples/bgfx-android/package_apk.shreproduces the CLI Android pipeline standalone:aapt2 link(NativeActivity manifest,lib_name=game) → stage.so→ zip (.so/arsc stored, R+ requirement) →zipalign -p→apksigner(debug keystore).Milestone C — runs on the tablet
adb install→ launch → bgfx init on-device.Verification (re-run independently of the author)
.so: generated theexamples/bgfx-androidproject (--platform android) andzig build→libgame.so:ELF 64-bit LSB shared object, ARM aarch64, exportingANativeActivity_onCreate+android_main, zero undefined sokol symbols.package_apk.sh→game.apk(15 MB,apksignersigned + verified).R9XR6009TJN):adb install -r→ Success; launched the NativeActivity; logcat:struct android_appreads correctly (valid native-window handle, lifecycle driven through Start/Resume/INIT_WINDOW/Resized).zig build+zig build test(incl. new bgfx-android codegen assertions intest/),backends/bgfxzig build test, and the bgfxexamplezig buildall green.Honest caveats / follow-ups
--platform android(no.platformfield in itsproject.labelle).