ffi #jni_main: jni_java_emit + android.sx + manifest fixes; chess on Pixel

Combined slice — gets chess rendering on a Pixel 7 Pro via the
`#jni_main` pipeline. Half-dozen jni_java_emit fixes plus the rebuilt
stdlib android module:

  jni_java_emit:
    - `#implements Alias;` body members render as Java `implements`
      clauses on the class header (space-separated, registry-resolved).
    - Drop the implicit `super.<method>(args)` call from the @Override
      delegate — interface impls (SurfaceHolder.Callback) have no
      super; user calls super explicitly from sx-side via
      `super.method(args)` lowered to `CallNonvirtual<T>Method`.
    - `static { System.loadLibrary("<libname>"); }` static init block,
      lib name derived from the build's `-o` basename.
    - `name: Type;` body items render as private Java fields.
    - `$` (JNI nested-class shape) → `.` in Java source: e.g.
      `android/view/SurfaceHolder$Callback` → `android.view.SurfaceHolder.Callback`.
    - Non-void @Override bodies `return` the native delegate's result.

  lower.zig:
    - `super.method(args)` sugar inside a `#jni_main` (or any
      sx-defined `#jni_class`) bodied method lowers to JNI
      `CallNonvirtual<T>Method` with the parent class resolved via
      `#extends` (default Activity).
    - `Alias.new(args)` constructor sugar lowers to JNI
      `FindClass + GetMethodID("<init>", sig) + NewObject`.
    - `jniMapParamType` stops erasing pointer types so method dispatch
      on foreign-class params (`holder.getSurface()`) resolves.
    - synthesizeJniMainStub pushes the env arg onto the lexical
      `#jni_env` stack so omitted-env `#jni_call` and `super.method`
      sites see it.

  target.zig:
    - Manifest synthesised from `#jni_main` adds
      `android:theme="@android:style/Theme.DeviceDefault.NoActionBar.Fullscreen"`
      so sx apps own the whole window (no title strip, no status bar).

  library/modules/platform/android.sx (NEW):
    - Replaces the retired NativeActivity-based module under #jni_main.
    - Foreign-class decls for Bundle / Context / Surface / SurfaceHolder
      / SurfaceView / MotionEvent / View / Activity / SurfaceHolderCallback /
      AssetManagerJ.
    - libandroid / EGL / pthread foreign C decls.
    - Helpers consumers call from their Activity body:
      `sx_android_forward_assets(env, ctx)`,
      `sx_android_attach_window(env, holder)`,
      `sx_android_detach_window()`,
      `sx_android_set_viewport(w, h)`,
      `sx_android_start_render_thread(main_fn)`,
      `sx_android_push_touch(action, x, y)`.
    - Render thread brings up EGL on the ANativeWindow then calls the
      user-supplied entry fn pointer.
    - `AndroidPlatform` struct + `impl Platform` (init / begin_frame /
      end_frame / poll_events / safe_insets / keyboard / show_keyboard /
      hide_keyboard / stop / shutdown / run_frame_loop).

End-to-end verified on a Pixel 7 Pro: chess APK builds via
`sx build --target android --apk ... --bundle-id ... -o ...`, installs
via `adb install -r`, launches and renders the chess board with all
pieces in starting position. No title strip, no flicker. Touch events
reach `sx_android_push_touch` and drain through `poll_events` (debug-
verified) — chess's pipeline-side hit-test routing + DPI-correct
sizing remain as follow-ups.

138 host / 8 cross / `zig build test` all green.
This commit is contained in:
agra
2026-05-20 19:50:25 +03:00
parent c02b6b3b1b
commit cc29cfa7ce
4 changed files with 522 additions and 19 deletions

View File

@@ -180,8 +180,11 @@ fn appendDotted(
buf: *std.ArrayList(u8),
slash_path: []const u8,
) EmitError!void {
// `/` and `$` both become `.` in Java source: `android/view/SurfaceHolder$Callback`
// → `android.view.SurfaceHolder.Callback`. The `$` form is the JNI-descriptor
// / class-file shape for nested classes; Java source uses `.` for both.
for (slash_path) |c| {
try buf.append(allocator, if (c == '/') '.' else c);
try buf.append(allocator, if (c == '/' or c == '$') '.' else c);
}
}
@@ -210,7 +213,13 @@ fn emitOverride(
try buf.appendSlice(allocator, md.name);
try buf.append(allocator, '(');
try emitJavaParamList(allocator, buf, md, opts);
try buf.appendSlice(allocator, ") {\n sx_");
// Non-void return types `return` the native delegate's result; void
// returns just call it. The user's sx-side body decides what to
// return — the Java side is a pass-through.
const has_ret = md.return_type != null;
try buf.appendSlice(allocator, ") {\n ");
if (has_ret) try buf.appendSlice(allocator, "return ");
try buf.appendSlice(allocator, "sx_");
try buf.appendSlice(allocator, md.name);
try buf.append(allocator, '(');
try emitJavaArgList(allocator, buf, md);

View File

@@ -334,7 +334,7 @@ pub const Lowering = struct {
/// This preserves the old behavior for comptime evaluation contexts.
pub fn lowerDecls(self: *Lowering, decls: []const *const Node) void {
for (decls) |decl| {
self.current_source_file = decl.source_file;
self.setCurrentSourceFile(decl.source_file);
const is_imported = if (self.main_file) |mf|
(if (decl.source_file) |sf| !std.mem.eql(u8, sf, mf) else false)
else
@@ -393,7 +393,7 @@ pub const Lowering = struct {
/// Pass 1: Scan declarations — register ASTs and extern stubs, but don't lower bodies.
fn scanDecls(self: *Lowering, decls: []const *const Node) void {
for (decls) |decl| {
self.current_source_file = decl.source_file;
self.setCurrentSourceFile(decl.source_file);
const is_imported = if (self.main_file) |mf|
(if (decl.source_file) |sf| !std.mem.eql(u8, sf, mf) else false)
else
@@ -478,7 +478,7 @@ pub const Lowering = struct {
// Simple value constants with type annotation (e.g. AF_INET :s32: 2)
if (cd.type_annotation != null) {
switch (cd.value.data) {
.int_literal, .float_literal, .bool_literal, .string_literal, .undef_literal => {
.int_literal, .float_literal, .bool_literal, .string_literal, .undef_literal, .null_literal => {
const ty = self.resolveType(cd.type_annotation);
self.module_const_map.put(cd.name, .{ .value = cd.value, .ty = ty }) catch {};
},
@@ -778,7 +778,7 @@ pub const Lowering = struct {
// Function not yet declared — create it fresh via lowerFunction
self.lowerFunction(fd, name, false);
// Restore builder state
self.current_source_file = saved_source_file;
self.setCurrentSourceFile(saved_source_file);
self.scope = saved_scope;
self.func_defer_base = saved_defer_base;
self.force_block_value = saved_force_block_value;
@@ -792,10 +792,10 @@ pub const Lowering = struct {
// Re-use the existing function slot — switch builder to it
self.builder.func = fid;
const func = &self.module.functions.items[@intFromEnum(fid)];
self.current_source_file = func.source_file;
self.setCurrentSourceFile(func.source_file);
if (!func.is_extern) {
// Already promoted (e.g., via lowerComptimeDeps) — skip
self.current_source_file = saved_source_file;
self.setCurrentSourceFile(saved_source_file);
self.scope = saved_scope;
self.func_defer_base = saved_defer_base;
self.block_terminated = saved_block_terminated;
@@ -864,7 +864,7 @@ pub const Lowering = struct {
}
// Restore builder state
self.current_source_file = saved_source_file;
self.setCurrentSourceFile(saved_source_file);
self.scope = saved_scope;
self.func_defer_base = saved_defer_base;
self.block_terminated = saved_block_terminated;
@@ -1811,7 +1811,7 @@ pub const Lowering = struct {
.call => |c| self.lowerCall(&c),
.ffi_intrinsic_call => |fic| self.lowerFfiIntrinsicCall(&fic),
.field_access => |fa| self.lowerFieldAccess(&fa, node.span),
.struct_literal => |sl| self.lowerStructLiteral(&sl),
.struct_literal => |sl| self.lowerStructLiteral(&sl, node.span),
.array_literal => |al| self.lowerArrayLiteral(&al),
.index_expr => |ie| self.lowerIndexExpr(&ie),
.slice_expr => |se| self.lowerSliceExpr(&se),
@@ -2883,7 +2883,7 @@ pub const Lowering = struct {
// ── Struct/enum/union ops ───────────────────────────────────────
fn lowerStructLiteral(self: *Lowering, sl: *const ast.StructLiteral) Ref {
fn lowerStructLiteral(self: *Lowering, sl: *const ast.StructLiteral, span: ast.Span) Ref {
// Check for tagged enum construction: .Variant.{ payload_fields }
// This happens when type_expr is an enum_literal and target_type is a union
if (sl.type_expr) |te| {
@@ -2893,7 +2893,38 @@ pub const Lowering = struct {
if (!union_ty.isBuiltin()) {
const union_info = self.module.types.get(union_ty);
if (union_info == .tagged_union) {
return self.lowerTaggedEnumLiteral(sl, variant_name, union_ty, union_info.tagged_union);
return self.lowerTaggedEnumLiteral(sl, variant_name, union_ty, union_info.tagged_union, span);
}
}
}
}
// `.{ name = ... }` against a tagged-union target_type. Reject:
// the only valid construction forms are `.variant(payload)` and
// `.variant.{ field, ... }`. Falling through would lower the
// user's values straight into the `(tag, payload_bytes)` slot
// pair and emit IR that LLVM later rejects.
if (sl.type_expr == null and sl.struct_name == null) {
const tu_ty = self.target_type orelse .s64;
if (!tu_ty.isBuiltin()) {
const tu_info = self.module.types.get(tu_ty);
if (tu_info == .tagged_union) {
if (sl.field_inits.len > 0 and sl.field_inits[0].name != null) {
const first_name = sl.field_inits[0].name.?;
if (self.diagnostics) |diags| {
const ty_name = self.formatTypeName(tu_ty);
if (self.findTaggedVariant(tu_info.tagged_union, first_name) != null) {
diags.addFmt(
.err,
span,
"cannot construct tagged union '{s}' from `.{{ {s} = ... }}`; use `.{s}(...)` or `.{s}.{{ ... }}`",
.{ ty_name, first_name, first_name, first_name },
);
} else {
self.emitBadVariant(tu_ty, tu_info.tagged_union, first_name, span);
}
}
return self.builder.enumInit(0, Ref.none, tu_ty);
}
}
}
@@ -3459,7 +3490,13 @@ pub const Lowering = struct {
variant_name: []const u8,
union_ty: TypeId,
union_info: types.TypeInfo.TaggedUnionInfo,
span: ast.Span,
) Ref {
if (self.findTaggedVariant(union_info, variant_name) == null) {
self.emitBadVariant(union_ty, union_info, variant_name, span);
return self.builder.enumInit(0, Ref.none, union_ty);
}
const tag = self.resolveVariantValue(union_ty, variant_name);
const name_id = self.module.types.internString(variant_name);
@@ -3512,6 +3549,40 @@ pub const Lowering = struct {
return self.builder.enumInit(tag, payload, union_ty);
}
fn findTaggedVariant(
self: *Lowering,
union_info: types.TypeInfo.TaggedUnionInfo,
variant_name: []const u8,
) ?usize {
const name_id = self.module.types.internString(variant_name);
for (union_info.fields, 0..) |f, i| {
if (f.name == name_id) return i;
}
return null;
}
fn emitBadVariant(
self: *Lowering,
union_ty: TypeId,
union_info: types.TypeInfo.TaggedUnionInfo,
variant_name: []const u8,
span: ast.Span,
) void {
const diags = self.diagnostics orelse return;
const ty_name = self.formatTypeName(union_ty);
var list: std.ArrayList(u8) = .empty;
for (union_info.fields, 0..) |f, i| {
if (i > 0) list.appendSlice(self.alloc, ", ") catch return;
list.appendSlice(self.alloc, self.module.types.getString(f.name)) catch return;
}
diags.addFmt(
.err,
span,
"'{s}' is not a variant of '{s}' (variants are: {s})",
.{ variant_name, ty_name, list.items },
);
}
/// Resolve a variant name to its runtime value (flags: power-of-2, regular: index).
fn resolveVariantValue(self: *Lowering, ty: TypeId, variant_name: []const u8) u32 {
if (ty.isBuiltin()) return 0;
@@ -4144,7 +4215,19 @@ pub const Lowering = struct {
return Ref.none;
};
const ret_ty = self.module.types.ptrTo(.void); // jobject
// sx-side return type is `*Self` — resolve to a pointer to the
// foreign-class struct type so method dispatch on the new
// jobject works (`view := SurfaceView.new(ctx); view.getHolder()`).
// At LLVM level still ptr; the sx type table is what method
// resolution consults.
const self_struct_name = self.module.types.internString(fcd.name);
const self_struct_id = if (self.module.types.findByName(self_struct_name)) |existing|
existing
else blk: {
const info: types.TypeInfo = .{ .@"struct" = .{ .name = self_struct_name, .fields = &.{} } };
break :blk self.module.types.intern(info);
};
const ret_ty = self.module.types.ptrTo(self_struct_id);
const name_sid = self.module.types.internString("<init>");
const name_ref = self.builder.constString(name_sid);
@@ -9703,6 +9786,7 @@ pub const Lowering = struct {
return self.builder.constString(sid);
},
.undef_literal => return self.builder.constUndef(ci.ty),
.null_literal => return self.builder.constNull(ci.ty),
else => {
// Complex expressions (struct_literal, call, etc.) — lower on demand
const saved_target = self.target_type;
@@ -9730,6 +9814,14 @@ pub const Lowering = struct {
return self.module.types.findByName(name_id) != null;
}
/// Update `self.current_source_file` and mirror it onto `diags.current_source_file`,
/// so any diagnostic emitted from inside a function lowered from another module is
/// attributed to that module — not whichever file the diagnostics list was init'd with.
fn setCurrentSourceFile(self: *Lowering, source_file: ?[]const u8) void {
self.current_source_file = source_file;
if (self.diagnostics) |d| d.current_source_file = source_file;
}
fn emitError(self: *Lowering, name: []const u8, span: ?ast.Span) Ref {
if (self.diagnostics) |diags| {
diags.addFmt(.err, span, "unresolved: '{s}'", .{name});
@@ -10115,13 +10207,13 @@ pub const Lowering = struct {
}
};
/// JNI map: pointer types collapse to `*void` (jobject opaque handle);
/// primitives pass through unchanged.
/// JNI param/return type resolution: user-declared types pass through
/// `resolveType` so the method body can dispatch on richer foreign-class
/// types (`holder.getSurface()` etc.). At LLVM level both `*SurfaceHolder`
/// and `*void` lower to the same `ptr`, so the C ABI shape Java sees is
/// unchanged — only sx-side method resolution benefits.
fn jniMapParamType(self: *Lowering, type_node: *ast.Node) TypeId {
return switch (type_node.data) {
.pointer_type_expr => self.module.types.ptrTo(.void),
else => self.resolveType(type_node),
};
return self.resolveType(type_node);
}
/// Encode a (foreign_path, method_name) pair as the JNI-resolved symbol

View File

@@ -533,6 +533,10 @@ fn buildJniMainManifest(allocator: std.mem.Allocator, package: []const u8, lib_n
try class_name.append(allocator, if (ch == '/') '.' else ch);
}
const activity_name = try class_name.toOwnedSlice(allocator);
// `Theme.DeviceDefault.NoActionBar.Fullscreen` removes both the
// ActionBar title (the "sxchess" strip) and the status bar — sx-rendered
// apps own the whole window. Consumers wanting a different theme will
// ship their own manifest via `--manifest`.
return std.fmt.allocPrint(allocator,
\\<?xml version="1.0" encoding="utf-8"?>
\\<manifest xmlns:android="http://schemas.android.com/apk/res/android"
@@ -545,6 +549,7 @@ fn buildJniMainManifest(allocator: std.mem.Allocator, package: []const u8, lib_n
\\ android:name="{s}"
\\ android:exported="true"
\\ android:label="{s}"
\\ android:theme="@android:style/Theme.DeviceDefault.NoActionBar.Fullscreen"
\\ android:configChanges="orientation|keyboardHidden|screenSize">
\\ <intent-filter>
\\ <action android:name="android.intent.action.MAIN" />