android: pure-sx EGL+GLES3 clear-color demo on NativeActivity

Verified on Pixel 7 Pro: solid purple frame, 'rendered 60 frames'
logcat line every second. End-to-end exercise of the new
sx-build → libsxhello.so → APK toolchain shipped today: NDK clang
link + native_app_glue bundling + aapt2/zipalign/apksigner pipeline +
isExportedEntryName so android_main lands in .dynsym.

Notes the source captures so future Android work doesn't repeat the
debugging:
  - android_app field offsets for arm64 NDK 29 (window @ 72,
    destroyRequested @ 100, source process fn-ptr @ 16).
  - ALooper_pollOnce(-1, ...) blows the stack inside Looper::pollOnce
    on this device/OS combo; ALooper_pollOnce(0, ...) is fine. We
    drive the event loop non-blocking and sleep 16ms.

Outside the regression set on purpose (no tests/expected/99-*.txt) —
same convention as 63-metal-clear.sx. Build instructions live in the
file's leading comment.
This commit is contained in:
agra
2026-05-18 23:16:36 +03:00
parent f66cda6d11
commit ba1d41a4f5

View File

@@ -0,0 +1,197 @@
// Android-only: pure-sx NativeActivity that brings up EGL on the
// ANativeWindow delivered by native_app_glue and clears the screen
// every frame via GLES3. Equivalent of `examples/63-metal-clear.sx`
// for the Android target.
//
// What this exercises end-to-end (Session 70's Android port):
// - `sx build --target android` toolchain (NDK clang, native_app_glue
// compile + link, -shared .so).
// - `--apk` APK assembly (manifest + aapt2 + zipalign + apksigner).
// - `android_main` and `ANativeActivity_onCreate` keep external LLVM
// linkage so the loader can call them (isExportedEntryName allowlist
// in lower.zig).
// - Foreign declarations of EGL / GLES3 / ALooper / __android_log_print.
//
// Build + install on a connected device:
//
// /Users/agra/projects/sx/zig-out/bin/sx build --target android \
// --apk /tmp/sxhello.apk --bundle-id co.swipelab.sxhello \
// -o /tmp/libsxhello.so examples/99-android-egl-clear.sx
// adb install -r /tmp/sxhello.apk
// adb shell am start -n co.swipelab.sxhello/android.app.NativeActivity
// adb logcat -d --pid=$(adb shell pidof co.swipelab.sxhello)
//
// Expected: solid purple frame on the device. `egl: setup ok` + periodic
// `rendered 60 frames` lines in logcat.
//
// Known quirk: `ALooper_pollOnce(-1, ...)` (blocking variant) crashes
// inside Looper::pollOnce on Pixel 7 Pro + Android 16 with what looks
// like a stack-guard overflow. `ALooper_pollOnce(0, ...)` is fine, so
// we drive the event loop non-blocking and sleep 16ms per tick instead
// of relying on the blocking timeout. Worth revisiting later.
#import "modules/std.sx";
// ── Foreign declarations ────────────────────────────────────────────────
__android_log_print :: (prio: s32, tag: *u8, fmt: *u8) -> s32 #foreign;
usleep :: (us: u32) -> s32 #foreign;
ALooper_pollOnce :: (ms: s32, outFd: *s32, outEvents: *s32, outData: **void) -> s32 #foreign;
// EGL — display/surface/context/config are opaque pointers from our side.
eglGetDisplay :: (display_id: *void) -> *void #foreign;
eglInitialize :: (display: *void, major: *s32, minor: *s32) -> u32 #foreign;
eglChooseConfig :: (display: *void, attrib_list: *s32, configs: **void, config_size: s32, num_config: *s32) -> u32 #foreign;
eglCreateContext :: (display: *void, config: *void, share: *void, attrib_list: *s32) -> *void #foreign;
eglCreateWindowSurface:: (display: *void, config: *void, window: *void, attrib_list: *s32) -> *void #foreign;
eglMakeCurrent :: (display: *void, draw: *void, read: *void, ctx: *void) -> u32 #foreign;
eglSwapBuffers :: (display: *void, surface: *void) -> u32 #foreign;
// GLES3 (linked via -lGLESv3)
glClearColor :: (r: f32, g: f32, b: f32, a: f32) #foreign;
glClear :: (mask: u32) #foreign;
// ── EGL/GL constants ───────────────────────────────────────────────────
EGL_NONE :s32: 0x3038;
EGL_SURFACE_TYPE :s32: 0x3033;
EGL_WINDOW_BIT :s32: 0x0004;
EGL_RENDERABLE_TYPE :s32: 0x3040;
EGL_OPENGL_ES2_BIT :s32: 0x0004;
EGL_BLUE_SIZE :s32: 0x3022;
EGL_GREEN_SIZE :s32: 0x3023;
EGL_RED_SIZE :s32: 0x3024;
EGL_DEPTH_SIZE :s32: 0x3025;
EGL_CONTEXT_CLIENT_VERSION :s32: 0x3098;
GL_COLOR_BUFFER_BIT :u32: 0x4000;
// ── android_app + android_poll_source layout (NDK 29, arm64) ───────────
// Offsets measured against the NDK header on this host with offsetof().
APP_OFF_window :s64: 72;
APP_OFF_destroyRequested :s64: 100;
SRC_OFF_process :s64: 16; // id(4) + pad(4) + app*(8) = process fn-ptr
// ── Helpers ────────────────────────────────────────────────────────────
log :: (msg: *u8) {
__android_log_print(4, "sxhello".ptr, msg);
}
read_ptr :: (base: s64, off: s64) -> *void {
p : **void = xx (base + off);
p.*;
}
read_s32 :: (base: s64, off: s64) -> s32 {
p : *s32 = xx (base + off);
p.*;
}
// ── EGL setup ──────────────────────────────────────────────────────────
EGLContext :: struct {
display: *void = null;
surface: *void = null;
context: *void = null;
config: *void = null;
}
egl_setup :: (egl: *EGLContext, window: *void) -> bool {
egl.display = eglGetDisplay(null);
if egl.display == null { log("egl: getDisplay failed\n".ptr); return false; }
major : s32 = 0;
minor : s32 = 0;
if eglInitialize(egl.display, @major, @minor) == 0 {
log("egl: initialize failed\n".ptr); return false;
}
attribs : [13]s32 = .{
EGL_SURFACE_TYPE, EGL_WINDOW_BIT,
EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
EGL_BLUE_SIZE, 8,
EGL_GREEN_SIZE, 8,
EGL_RED_SIZE, 8,
EGL_DEPTH_SIZE, 0,
EGL_NONE,
};
num_config : s32 = 0;
if eglChooseConfig(egl.display, @attribs[0], @egl.config, 1, @num_config) == 0 or num_config < 1 {
log("egl: chooseConfig failed\n".ptr); return false;
}
ctx_attribs : [3]s32 = .{ EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE };
egl.context = eglCreateContext(egl.display, egl.config, null, @ctx_attribs[0]);
if egl.context == null { log("egl: createContext failed\n".ptr); return false; }
egl.surface = eglCreateWindowSurface(egl.display, egl.config, window, null);
if egl.surface == null { log("egl: createWindowSurface failed\n".ptr); return false; }
if eglMakeCurrent(egl.display, egl.surface, egl.surface, egl.context) == 0 {
log("egl: makeCurrent failed\n".ptr); return false;
}
log("egl: setup ok\n".ptr);
true;
}
// ── android_main ───────────────────────────────────────────────────────
// native_app_glue invokes us on a worker thread once the activity is
// up. We poll the looper non-blocking, drain any pending sources (their
// `process` callbacks are what populate app->window for us), then
// either wait for the window or render a frame.
android_main :: (app: *void) {
log("android_main: enter\n".ptr);
base : s64 = xx app;
egl : EGLContext = .{};
out_fd : s32 = 0;
out_events : s32 = 0;
out_data : *void = null;
frame : s32 = 0;
while true {
ret := ALooper_pollOnce(0, @out_fd, @out_events, @out_data);
if ret >= 0 {
if out_data != null {
src_base : s64 = xx out_data;
process_addr := read_ptr(src_base, SRC_OFF_process);
if process_addr != null {
process_fn : (*void, *void) -> void = xx process_addr;
process_fn(app, out_data);
}
}
}
if read_s32(base, APP_OFF_destroyRequested) != 0 {
log("android_main: destroy requested\n".ptr);
break;
}
window := read_ptr(base, APP_OFF_window);
if window != null and egl.surface == null {
if egl_setup(@egl, window) == false {
log("android_main: egl setup failed\n".ptr);
break;
}
}
if egl.surface != null {
// Steady purple — same role as 63-metal-clear's blue: prove
// the GPU touched the framebuffer.
glClearColor(0.5, 0.2, 0.8, 1.0);
glClear(GL_COLOR_BUFFER_BIT);
eglSwapBuffers(egl.display, egl.surface);
frame += 1;
if (frame % 60) == 0 {
log("android_main: rendered 60 frames\n".ptr);
}
}
usleep(16000);
}
}