Skip to content

feat(bgfx): Android codegen + .so link + APK, runs on-device (phase 4, #303) - #309

Merged
apotema merged 3 commits into
mainfrom
feat/bgfx-android-p4-codegen
Jun 13, 2026
Merged

feat(bgfx): Android codegen + .so link + APK, runs on-device (phase 4, #303)#309
apotema merged 3 commits into
mainfrom
feat/bgfx-android-p4-codegen

Conversation

@apotema

@apotema apotema commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

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_android codegen + .so link

  • src/templates/build_zig.txt: new backend_bgfx_android, android_link_bgfx, android_exe_app_import sections; src/build_files.zig wires the .bgfx+.android dispatch. The generated build.zig fetches the bgfx backend for aarch64-linux-android (gfx/input/audio/window, no zglfw), pulls the android_app glue module, builds a dynamic lib, and links bgfx + android/log/EGL/GLESv3/m/dl + NDK libc.
  • Generated main.zig (src/codegen/main_template.zig + new backends/bgfx/templates/android.txt) owns the android_main entry: registers a one-shot engine-init callback + per-frame tick with the bgfx shell, then runs the loop. backends/bgfx/src/android_app.zig gains setInitCallback (fires after window.initWindow on first INIT_WINDOW) and gates its own android_main export on !game_owns_main. Desktop bgfx is untouched (keeps the glfw exe loop).
  • Compat shims: removing sokol exposed sokol-coupling in core/engine's Android paths — the generated main.zig shims sapp_android_get_native_activity (forwards to the bgfx shell's stored activity) and labelle_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.sh reproduces the CLI Android pipeline standalone: aapt2 link (NativeActivity manifest, lib_name=game) → stage .so → zip (.so/arsc stored, R+ requirement) → zipalign -papksigner (debug keystore).

Milestone C — runs on the tablet

adb install → launch → bgfx init on-device.

Verification (re-run independently of the author)

  • .so: generated the examples/bgfx-android project (--platform android) and zig buildlibgame.so: ELF 64-bit LSB shared object, ARM aarch64, exporting ANativeActivity_onCreate + android_main, zero undefined sokol symbols.
  • APK: package_apk.shgame.apk (15 MB, apksigner signed + verified).
  • On-device (tablet R9XR6009TJN): adb install -r → Success; launched the NativeActivity; logcat:
    threaded_app: NativeWindowCreated ... APP_CMD_INIT_WINDOW
    BGFX Init...
    BGFX Version 1.142.9149
    BGFX Reset back-buffer swap chain: 2000x1200, formatColor: BGRA8, formatDepthStencil: D24S8 ... [x] V-sync
    
    bgfx initialized a 2000×1200 swapchain at the tablet's native resolution, no crash — the phase-3 hand-declared struct android_app reads correctly (valid native-window handle, lifecycle driven through Start/Resume/INIT_WINDOW/Resized).
  • Desktop unchanged: assembler zig build + zig build test (incl. new bgfx-android codegen assertions in test/), backends/bgfx zig build test, and the bgfx example zig build all green.

Honest caveats / follow-ups

  • Sustained frame loop not observed: the test device dozes (screen-off → Pause/TERM_WINDOW); bgfx init+swapchain fire every time the surface comes up and tear down cleanly, but I couldn't watch a continuous rendered loop with the screen forced on long enough. Init is proven; sustained on-screen frames need a kept-awake device.
  • Audio is silent on Android (device-less mixer, bgfx Android: expose a device-less audio module (phase-4 prerequisite) #306) — AAudio output is a later task.
  • Gamepad is inert on bgfx-Android (shimmed) and the sokol-coupling shims want a proper abstraction in core/engine — filed as a follow-up.
  • The example relies on --platform android (no .platform field in its project.labelle).

#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.
@cursor

cursor Bot commented Jun 13, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches Android entry-point ownership, NDK linking, and lifecycle timing (init after bgfx surface); desktop paths are gated but codegen dispatch errors could affect wrong platform builds.

Overview
Phase 4 bgfx-on-Android: the assembler can generate Android bgfx projects that build libgame.so and run as NativeActivity apps. Desktop bgfx (GLFW loop) is unchanged.

Codegen & build: For backend == .bgfx and platform == .android, build.zig now uses backend_bgfx_android (no zglfw), imports android_app as backend_app, links via android_link_bgfx, and produces a dynamic library. main.zig uses a new callback lifecycle (backends/bgfx/templates/android.txt) instead of pub fn main(): the game exports android_main, sets labelle_provides_android_main, registers gameInit / gameFrame, and calls android_app.run.

Backend shell: android_app.zig adds one-shot setInitCallback after first INIT_WINDOW, optional game-owned android_main via root labelle_provides_android_main, stashes ANativeActivity for getNativeActivity(), and skips duplicate android_main export when the game owns entry. window.zig exposes getScreenWidth / getScreenHeight for real surface size vs config.

Sokol-compat shims in generated Android main.zig: sapp_android_get_native_activity and no-op labelle_android_gamepad_* so engine/core Android paths link without sokol.

Example & tests: New examples/bgfx-android (manifest, package_apk.sh, README) plus assertions in build_zig_tests and main_zig_tests for the Android bgfx path.

Reviewed by Cursor Bugbot for commit 17d2491. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread backends/bgfx/templates/android.txt

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread backends/bgfx/src/android_app.zig Outdated
Comment on lines +46 to +47
const game_owns_main = @hasDecl(root, "labelle_provides_android_main") and
root.labelle_provides_android_main;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.
@apotema

apotema commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

Bot triage — addressed in the latest commit:

  • Gfx screen size not synced to the real surface (Cursor, medium) — fixed. 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 (2000×1200 on the test tablet) — mis-mapping gizmo/UI coordinates. Added window.getScreenWidth()/getScreenHeight() (the real post-initWindow size); the generated Android gameInit now feeds window.getScreenHeight() to g.setScreenHeight. Verified the regenerated .so carries it and links.
  • game_owns_main @hasDecl(...) and root.<decl> (Gemini, high) — the bare form already compiles (Zig lazily short-circuits and at comptime — CI proves it), but rewrote as explicit if/else to remove any reader doubt.
  • Hardcoded dt = 0.016 (Gemini, medium) — declined. This is the toolkit-wide convention for generated mains: the raylib + sokol desktop lifecycle templates and the preview-mode test fixtures all use const dt: f32 = 0.016. Changing it only for bgfx-Android would make it diverge from every other backend. Real-frame-delta is a separate, global decision (not a phase-4 item).

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread backends/bgfx/src/android_app.zig

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.zig codegen to use a callback-driven lifecycle for bgfx on Android (game-owned android_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.

Comment on lines +429 to +431
pub fn getNativeActivity() ?*anyopaque {
return @ptrCast(native_activity);
}
Comment on lines +3 to +7
// 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.
@apotema

apotema commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

Second-round bot triage — addressed in the latest commit:

  • Screen metrics stale after resume (Cursor, medium) — fixed. The surface can be torn down + recreated at a different size (rotation, pause/resume) without gameInit re-running, so my once-at-init height sync would go stale. The generated gameFrame now re-syncs g.setScreenHeight(window.getScreenHeight()) every frame (a cheap field write) — matching the sokol Android template's per-frame dimension refresh. (gameInit keeps its sync so setup code sees the right size immediately.)
  • c++_shared link comment (Copilot) — fixed. The example project.labelle comment claimed the link pulls libc++_shared.so; it doesn't (the C++ runtime comes from Zig's toolchain). Comment corrected to android, log, EGL, GLESv3, m, dl.
  • getNativeActivity @ptrCast (Copilot) — declined. @ptrCast handles optional→optional pointers in Zig 0.16; @ptrCast(?*ANativeActivity) -> ?*anyopaque compiles (CI is green) and the activity resolves correctly on-device (bgfx initialized against it). No unwrap/null-check is required.

Verified: assembler codegen tests green; regenerated bgfx-android .so links with the per-frame height sync.

@apotema
apotema merged commit 7b1f1dc into main Jun 13, 2026
5 checks passed
@apotema
apotema deleted the feat/bgfx-android-p4-codegen branch June 13, 2026 01:50
apotema added a commit that referenced this pull request Jun 13, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bgfx Android (phase 4): backend_bgfx_android codegen + APK packaging + tablet deploy

2 participants