android: Platform-owned entry bridge + .android OS enum variant
User writes BOTH `main` and a 3-line `android_main(app)` trampoline.
The library provides `sx_android_bootstrap(app)` (stashes the NDK app
pointer into a platform-owned global) and `AndroidPlatform` impl of
the Platform protocol. The library NEVER references `main` — the OS-
shape entry symbol lives in user code where the other entry symbols
already live. iOS / SDL3 keep their existing shape; only Android adds
the trampoline.
Cross-cutting bits this commit ships:
library/modules/compiler.sx
Add `android` variant to `OperatingSystem`.
src/ir/lower.zig
- injectComptimeConstants: map TargetConfig.isAndroid() → .android.
- New Pass 4 `checkRequiredEntryPoints`: emit a clean diagnostic
when `--target android` is requested but `android_main` isn't
defined, instead of letting the user crash on a dlopen-time
missing-symbol error.
library/modules/platform/android.sx
AndroidPlatform impl of the Platform protocol — EGL bringup on
`APP_CMD_INIT_WINDOW`, ALooper(0) polling, dispatches the user's
frame closure each ~16 ms tick. `sx_android_bootstrap(app)` is the
only function exposed for the entry trampoline.
examples/99-android-egl-clear.sx
Rewritten to use the new pattern: minimum `main` + `android_main`
pair, AndroidPlatform-driven render loop. Doubles as the usage
reference users hand off to the compiler diagnostic.
Verified on Pixel 7 Pro: purple clear-color frame, periodic
`rendered 60 frames` logcat lines. iOS-sim chess + 86/86 regression
tests pass.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
OperatingSystem :: enum { macos; linux; windows; wasm; ios; unknown; }
|
||||
OperatingSystem :: enum { macos; linux; windows; wasm; ios; android; unknown; }
|
||||
Architecture :: enum { aarch64; x86_64; wasm32; wasm64; unknown; }
|
||||
|
||||
OS : OperatingSystem = .unknown;
|
||||
|
||||
344
library/modules/platform/android.sx
Normal file
344
library/modules/platform/android.sx
Normal file
@@ -0,0 +1,344 @@
|
||||
// Pure-NDK NativeActivity backend for Android.
|
||||
//
|
||||
// Linking is per-target via the consumer's build.sx — for Android we
|
||||
// don't need explicit framework adds because `sx build --target android`
|
||||
// already links -llog -landroid -lEGL -lGLESv3 and bundles
|
||||
// native_app_glue.c. The file compiles cleanly on every target because
|
||||
// every NDK-touching path is gated by `inline if OS == .android`.
|
||||
//
|
||||
// Entry-point contract (deliberately kept symmetric with iOS):
|
||||
// - The user writes both `main` and `android_main` at top level.
|
||||
// - The user's `android_main(app)` calls `sx_android_bootstrap(app)`
|
||||
// once (to stash the NDK app pointer in this module's globals),
|
||||
// then calls `main()`. The body of `android_main` should be
|
||||
// `inline if OS == .android`-gated; on non-Android targets it
|
||||
// compiles to dead code, harmless because nothing references the
|
||||
// symbol.
|
||||
// - User's `main` constructs an `AndroidPlatform`, calls `init`,
|
||||
// then `run_frame_loop(closure)` which drives the looper until
|
||||
// destroyRequested. This file never names `main`.
|
||||
|
||||
#import "modules/std.sx";
|
||||
#import "modules/compiler.sx";
|
||||
#import "modules/ui/types.sx";
|
||||
#import "modules/ui/events.sx";
|
||||
#import "modules/platform/types.sx";
|
||||
#import "modules/platform/api.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;
|
||||
|
||||
ANativeWindow_getWidth :: (window: *void) -> s32 #foreign;
|
||||
ANativeWindow_getHeight :: (window: *void) -> s32 #foreign;
|
||||
|
||||
// EGL — display/surface/context/config are opaque to us.
|
||||
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;
|
||||
eglDestroySurface :: (display: *void, surface: *void) -> u32 #foreign;
|
||||
eglDestroyContext :: (display: *void, ctx: *void) -> u32 #foreign;
|
||||
eglTerminate :: (display: *void) -> u32 #foreign;
|
||||
|
||||
clock_gettime :: (clk_id: s32, ts: *void) -> s32 #foreign;
|
||||
|
||||
// ── 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_OPENGL_ES3_BIT :s32: 0x0040;
|
||||
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;
|
||||
|
||||
// CLOCK_MONOTONIC = 1 on linux/bionic. Used for delta_time + target_present_time.
|
||||
CLOCK_MONOTONIC :s32: 1;
|
||||
|
||||
// android_app + android_poll_source field offsets (NDK 29, arm64).
|
||||
// Recompute via offsetof() on a host with the NDK headers if you suspect
|
||||
// the layout has changed (see examples/99-android-egl-clear.sx's notes).
|
||||
APP_OFF_window :s64: 72;
|
||||
APP_OFF_destroyRequested :s64: 100;
|
||||
SRC_OFF_process :s64: 16; // id(4) + pad(4) + app*(8) = process fn-ptr
|
||||
|
||||
// timespec on linux/aarch64: tv_sec (s64) + tv_nsec (s64).
|
||||
TimeSpec :: struct { sec: s64; nsec: s64; }
|
||||
|
||||
// ── Globals ────────────────────────────────────────────────────────────
|
||||
// `g_android_app` is populated by `sx_android_bootstrap` before
|
||||
// `AndroidPlatform.run_frame_loop` reads it. `g_android_plat` is set
|
||||
// by `AndroidPlatform.init` so future hooks (signal handlers, JNI
|
||||
// callbacks) can find the live platform.
|
||||
|
||||
g_android_app : *void = null;
|
||||
g_android_plat : *AndroidPlatform = null;
|
||||
|
||||
// ── Bootstrap (called by user's `android_main`) ────────────────────────
|
||||
// Stashes the NDK app pointer the OS handed to `android_main(app)` so
|
||||
// the rest of the platform module can find it. Single responsibility —
|
||||
// the user's `android_main` calls this once, then calls their own
|
||||
// `main()` to enter the normal cross-platform setup flow.
|
||||
|
||||
sx_android_bootstrap :: (app: *void) {
|
||||
g_android_app = app;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
read_ptr :: (base: s64, off: s64) -> *void {
|
||||
inline if OS != .android { return null; }
|
||||
p : **void = xx (base + off);
|
||||
p.*;
|
||||
}
|
||||
|
||||
read_s32 :: (base: s64, off: s64) -> s32 {
|
||||
inline if OS != .android { return 0; }
|
||||
p : *s32 = xx (base + off);
|
||||
p.*;
|
||||
}
|
||||
|
||||
monotonic_seconds :: () -> f64 {
|
||||
inline if OS != .android { return 0.0; }
|
||||
ts : TimeSpec = .{};
|
||||
clock_gettime(CLOCK_MONOTONIC, xx @ts);
|
||||
(xx ts.sec) + (xx ts.nsec) / 1000000000.0;
|
||||
}
|
||||
|
||||
// ── Platform implementation ────────────────────────────────────────────
|
||||
|
||||
AndroidPlatform :: struct {
|
||||
// EGL state — created when ANativeWindow arrives via the event loop.
|
||||
egl_display: *void = null;
|
||||
egl_surface: *void = null;
|
||||
egl_context: *void = null;
|
||||
egl_config: *void = null;
|
||||
|
||||
// Latest known dimensions reported by the window. Refreshed each frame
|
||||
// from ANativeWindow_getWidth/Height — cheap and avoids missing resize
|
||||
// events when the user rotates the device.
|
||||
pixel_w: s32 = 0;
|
||||
pixel_h: s32 = 0;
|
||||
dpi_scale: f32 = 1.0; // TODO: query AConfiguration_getDensity
|
||||
delta_time: f32 = 0.016;
|
||||
last_frame_time: f64 = 0.0;
|
||||
|
||||
// User's per-frame closure stored when run_frame_loop is called.
|
||||
// Optional sentinel-shape — `null` means "no closure yet".
|
||||
frame_closure: ?Closure() = null;
|
||||
|
||||
events: List(Event) = .{};
|
||||
stop_requested: bool = false;
|
||||
|
||||
safe_top: f32 = 0.0;
|
||||
safe_left: f32 = 0.0;
|
||||
safe_bottom: f32 = 0.0;
|
||||
safe_right: f32 = 0.0;
|
||||
|
||||
keyboard_visible: bool = false;
|
||||
keyboard_height: f32 = 0.0;
|
||||
}
|
||||
|
||||
impl Platform for AndroidPlatform {
|
||||
// title/w/h are advisory only — the OS owns the surface dimensions.
|
||||
// We register the platform globally so android_main path can find it.
|
||||
init :: (self: *AndroidPlatform, title: [:0]u8, w: s32, h: s32) -> bool {
|
||||
inline if OS != .android { return false; }
|
||||
g_android_plat = self;
|
||||
true;
|
||||
}
|
||||
|
||||
run_frame_loop :: (self: *AndroidPlatform, frame_fn: Closure()) {
|
||||
inline if OS == .android {
|
||||
self.frame_closure = frame_fn;
|
||||
android_run_loop(self);
|
||||
}
|
||||
}
|
||||
|
||||
poll_events :: (self: *AndroidPlatform) -> []Event {
|
||||
out : []Event = .{ ptr = xx self.events.items, len = self.events.len };
|
||||
out;
|
||||
}
|
||||
|
||||
begin_frame :: (self: *AndroidPlatform) -> FrameContext {
|
||||
viewport_w_f : f32 = xx self.pixel_w;
|
||||
viewport_h_f : f32 = xx self.pixel_h;
|
||||
if self.dpi_scale > 0.0 {
|
||||
viewport_w_f = viewport_w_f / self.dpi_scale;
|
||||
viewport_h_f = viewport_h_f / self.dpi_scale;
|
||||
}
|
||||
FrameContext.{
|
||||
viewport_w = viewport_w_f,
|
||||
viewport_h = viewport_h_f,
|
||||
pixel_w = self.pixel_w,
|
||||
pixel_h = self.pixel_h,
|
||||
dpi_scale = self.dpi_scale,
|
||||
delta_time = self.delta_time,
|
||||
target_present_time = 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
end_frame :: (self: *AndroidPlatform) {
|
||||
inline if OS == .android {
|
||||
if self.egl_surface != null {
|
||||
eglSwapBuffers(self.egl_display, self.egl_surface);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
safe_insets :: (self: *AndroidPlatform) -> EdgeInsets {
|
||||
EdgeInsets.{
|
||||
top = self.safe_top,
|
||||
left = self.safe_left,
|
||||
bottom = self.safe_bottom,
|
||||
right = self.safe_right,
|
||||
};
|
||||
}
|
||||
|
||||
keyboard :: (self: *AndroidPlatform) -> KeyboardState {
|
||||
KeyboardState.{
|
||||
visible = self.keyboard_visible,
|
||||
height = self.keyboard_height,
|
||||
};
|
||||
}
|
||||
|
||||
show_keyboard :: (self: *AndroidPlatform) {
|
||||
// TODO: InputMethodManager.showSoftInput via JNI.
|
||||
}
|
||||
|
||||
hide_keyboard :: (self: *AndroidPlatform) {
|
||||
// TODO: InputMethodManager.hideSoftInputFromWindow via JNI.
|
||||
}
|
||||
|
||||
stop :: (self: *AndroidPlatform) {
|
||||
self.stop_requested = true;
|
||||
}
|
||||
|
||||
shutdown :: (self: *AndroidPlatform) {
|
||||
inline if OS == .android {
|
||||
if self.egl_display != null {
|
||||
eglMakeCurrent(self.egl_display, null, null, null);
|
||||
if self.egl_surface != null {
|
||||
eglDestroySurface(self.egl_display, self.egl_surface);
|
||||
}
|
||||
if self.egl_context != null {
|
||||
eglDestroyContext(self.egl_display, self.egl_context);
|
||||
}
|
||||
eglTerminate(self.egl_display);
|
||||
self.egl_display = null;
|
||||
self.egl_surface = null;
|
||||
self.egl_context = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal: event loop + EGL bringup ─────────────────────────────────
|
||||
|
||||
android_run_loop :: (self: *AndroidPlatform) {
|
||||
inline if OS != .android { return; }
|
||||
if g_android_app == null { return; }
|
||||
|
||||
app_base : s64 = xx g_android_app;
|
||||
out_fd : s32 = 0;
|
||||
out_events : s32 = 0;
|
||||
out_data : *void = null;
|
||||
|
||||
self.last_frame_time = monotonic_seconds();
|
||||
|
||||
while self.stop_requested == false {
|
||||
// Drain pending events. Non-blocking pollOnce is mandatory on
|
||||
// Pixel 7 Pro + Android 16 — pollOnce(-1) blows the stack inside
|
||||
// Looper::pollOnce there (see examples/99-android-egl-clear.sx's
|
||||
// notes for the investigation).
|
||||
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(g_android_app, out_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if read_s32(app_base, APP_OFF_destroyRequested) != 0 { break; }
|
||||
|
||||
window := read_ptr(app_base, APP_OFF_window);
|
||||
if window != null and self.egl_surface == null {
|
||||
if android_setup_egl(self, window) == false { break; }
|
||||
}
|
||||
|
||||
if self.egl_surface != null {
|
||||
// Refresh dimensions every tick — handles rotations cleanly.
|
||||
self.pixel_w = ANativeWindow_getWidth(window);
|
||||
self.pixel_h = ANativeWindow_getHeight(window);
|
||||
|
||||
now := monotonic_seconds();
|
||||
dt : f64 = now - self.last_frame_time;
|
||||
if dt > 0.0 { self.delta_time = xx dt; }
|
||||
self.last_frame_time = now;
|
||||
|
||||
// `if let`-style unwrap: only invoke if the closure was set.
|
||||
// User's frame closure is expected to call plat.end_frame()
|
||||
// which swaps — matches uikit.sx's begin/end contract.
|
||||
if frame_fn := self.frame_closure {
|
||||
frame_fn();
|
||||
}
|
||||
}
|
||||
|
||||
usleep(16000);
|
||||
}
|
||||
}
|
||||
|
||||
android_setup_egl :: (self: *AndroidPlatform, window: *void) -> bool {
|
||||
inline if OS != .android { return false; }
|
||||
|
||||
self.egl_display = eglGetDisplay(null);
|
||||
if self.egl_display == null { return false; }
|
||||
|
||||
major : s32 = 0;
|
||||
minor : s32 = 0;
|
||||
if eglInitialize(self.egl_display, @major, @minor) == 0 { return false; }
|
||||
|
||||
attribs : [13]s32 = .{
|
||||
EGL_SURFACE_TYPE, EGL_WINDOW_BIT,
|
||||
EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_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(self.egl_display, @attribs[0], @self.egl_config, 1, @num_config) == 0 or num_config < 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
ctx_attribs : [3]s32 = .{ EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE };
|
||||
self.egl_context = eglCreateContext(self.egl_display, self.egl_config, null, @ctx_attribs[0]);
|
||||
if self.egl_context == null { return false; }
|
||||
|
||||
self.egl_surface = eglCreateWindowSurface(self.egl_display, self.egl_config, window, null);
|
||||
if self.egl_surface == null { return false; }
|
||||
|
||||
if eglMakeCurrent(self.egl_display, self.egl_surface, self.egl_surface, self.egl_context) == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.pixel_w = ANativeWindow_getWidth(window);
|
||||
self.pixel_h = ANativeWindow_getHeight(window);
|
||||
true;
|
||||
}
|
||||
Reference in New Issue
Block a user