# sx FFI — Checkpoint Companion to `current/PLAN-FFI.md`. Update after every commit; one step at a time per the plan's cadence rule (no commit may both add a test and make it pass — that's two commits). ## Last completed step **issue-0043 closed — `#foreign` C-variadic tail via `args: ..T`.** A trailing variadic param on a `#foreign` declaration now maps to the C calling convention's `...` instead of sx's slice-packing path. Drops the existing per-arity shim pattern (`__log_2i :: (prio, tag, fmt, a: s32, b: s32) -> s32 #foreign __android_log_print;`) for a single declarative form: ```sx sx_ffi_sum_ints :: (n: s32, args: ..s32) -> s64 #foreign; main :: () -> s32 { print("{}\n", sx_ffi_sum_ints(3, 10, 20, 30)); // → 60 } ``` Three pieces shipped together (no separate cadence slices — the test locks in the green state in one commit): 1. **IR + emit_llvm**. `Function.is_variadic` ([src/ir/inst.zig](src/ir/inst.zig)); `declareFunction` ([src/ir/lower.zig:671](src/ir/lower.zig#L671)) detects a foreign+variadic-tail decl, drops the variadic param from the IR signature, and sets the flag. `emitFunctionDecl` ([src/ir/emit_llvm.zig:682](src/ir/emit_llvm.zig#L682)) passes `is_var_arg=1` to `LLVMFunctionType` when the flag is set; the per-call-site `LLVMBuildCall2` already passes all args through, so extras land in the variadic slot via the linker-fixed C ABI. 2. **Skip slice-packing**. `packVariadicCallArgs` ([src/ir/lower.zig:6354](src/ir/lower.zig#L6354)) early-outs for foreign+variadic so extras stay as individual refs instead of getting boxed into a typed slice. 3. **C default argument promotion**. New `promoteCVariadicArgs` ([src/ir/lower.zig](src/ir/lower.zig)) applies the standard promotions to args past the fixed param count: `bool/s8/s16/u8/u16 → s32` via sext/zext, `f32 → f64` via fpext. Wired into the two `lowerCall` paths right after `coerceCallArgs`. `examples/ffi-foreign-cvariadic.sx` + `.c` lock the matrix end-to-end: `sum_ints(3, 10, 20, 30) → 60`, `sum_ints(0) → 0`, `avg_doubles(2, 1.5, 2.5) → 2.0`, `avg_doubles(3, 1.0, 2.0, 3.0) → 2.0`, and a null-terminated `count_args` chain of `*u8` strings → `3`. All four return shapes (s64 / f64 / s32) and three element types (s32 / f64 / *u8) exercise the variadic-slot ABI through the C `va_arg` machinery in the .c helper. `examples/issue-0043.sx` retired (placeholder stub had no expected output; the focused feature example above is the new pin point). 150 host + 10 cross-compile tests pass. Stale snapshots re-pinned in the same commit: 12 IR/.txt files that drifted from in-progress std.sx additions (`xml_escape`, `path_join`) and the `BuildOptions.set_post_link_*` work. All diffs were verified to be either new dead extern decls, string-slot renumbering, or UB-driven struct fields — no semantic changes. Recent landings (working back from the head of the Log section): | When | What | |------------|-------------------------------------------------------------------| | 2026-05-22 | issue-0043 — `#foreign` C-variadic `args: ..T` end-to-end | | 2026-05-21 | Phase 3 step 3.0 — Obj-C DSL dispatch + default selector mangling | | 2026-05-20 | JNI byte/short/char return + varargs promotion (sext/zext/fpext) | | 2026-05-20 | JNI parameter validator lifted to lowering with source spans | | 2026-05-20 | JNI return-type validator lifted from emit_llvm into lowering | | 2026-05-20 | Silent-`undef` sweep — ~25 emit_llvm sites → diagnostic + undef | | 2026-05-20 | Chess-on-Pixel touch fix (missing `.f32` row in JNI Call-T switch) | | 2026-05-20 | Chess-on-Pixel size fix (android.sx refactored to zero globals) | | 2026-05-20 | issue-0044 — `#jni_main` body deferred-type-fn lowering order | **Phase 1.0–1.5 — `#objc_call` end-to-end for void return, with selector interning matching clang's lowering shape.** Six small commits: | # | Commit (oneline) | |-----|---------------------------------------------------------------------| | 1.0 | xfail parser test for `#objc_call(T)(recv, "sel:", args...)` | | 1.1 | parser + AST + sema + LSP recognize all three intrinsics | | 1.2 | xfail-then-green parser tests for `#jni_call` / `#jni_static_call` | | 1.3 | codegen for `#objc_call(void)(recv, "sel:")` — per-call lookup | | 1.4 | shared-selector regression test + IR-snapshot harness in `run_examples.sh` | | 1.5 | selector interning — static `SEL*` slot per unique name, populated by `@llvm.global_ctors` constructor; hot path collapses to one load | The IR-snapshot harness (`tests/expected/.ir` alongside `.txt`/`.exit`) lets us assert lowering shape without runtime side-effects; the selector-sharing test was the first to use it and pinned the 4→2 `sel_registerName` collapse. `@OBJC_METH_VAR_NAME_` private string literals with `unnamed_addr` + the `@llvm.global_ctors` constructor matches clang's `@selector(...)` lowering byte-for-byte enough that the system linker picks the right Mach-O sections on macOS/iOS. **Phase 0 complete — 10 baseline FFI tests + cross-compile scaffold, plus 2 codegen fixes surfaced along the way.** | # | Name | Notes | |------|-------------------------------|---------------------------------------------------------------------------------------| | 0.0 | tests/cross_compile.sh | empty tuple list, exits 0; skip-with-warning when toolchains missing | | 0.1 | ffi-01-primitives.sx | every primitive type round-trips through `#import c { #source / #include }` | | 0.2 | ffi-02-small-struct.sx | Vec2 (8 B), Vec4f (16 B HFA), Pair64 (2×s64), Quad32 (4×s32) — four ABI slots | | 0.3 | ffi-03-large-struct.sx | Big24 (24 B), Big48 (48 B) via byval params + sret return | | 0.4 | ffi-04-fp-struct.sx | FQuad (16 B HFA), DQuad (32 B HFA — UIEdgeInsets-shape) | | 0.5 | ffi-05-string-args.sx | [:0]u8, sx `string` slice-decay, [*]u8 + len, mutate-via-C, C-returned pointer | | 0.6 | ffi-06-callback.sx | sx fn -> C fn-pointer; single-arg + (ctx-ptr, value) forms; side effects via globals | | 0.7 | ffi-07-c-import-block.sx | `#import c { #include / #source }` resolves via stdlib-path search (library/vendors/) | | 0.8 | ffi-08-foreign-in-method.sx | `#foreign` from struct method / protocol impl / closure / `inline if OS == { case }` | | 0.9 | ffi-09-foreign-result-chain.sx| Opaque handle: chain, struct field, `List(*void)` iteration | | 0.10 | 94-foreign-global.sx | Extended with cross-file companion; both files declare the same `#foreign` global | ### Codegen fixes landed in Phase 0 - **issue-0036 / promoted to `101-ffi-medium-struct.sx`**. `coerceArg` in `emit_llvm.zig` learned struct↔array bridges (abi.struct2arr / abi.arr2struct) so 9..16 B integer-only foreign decls don't trip the LLVM verifier with `[2 x i64]` vs `{ i64, i64 }` mismatches. - **>16 B struct sret return**. `emitFunctionDecl` now collapses the ret type to void, prepends a `ptr` param at index 0 with the `sret()` type attribute, and the `.call` lowering mirrors the attribute + loads from the slot. AAPCS64 x8 / SysV AMD64 hidden-ptr ABI now round-trips. (Surfaced as a segfault on first Big24 call.) - **imports.zig stale-ci-snapshot bug**. The `#import c { #include }` resolved-paths weren't being read by `processCImport` because `const ci = decl.data.c_import_decl;` captured before the mutation. Re-binding after the resolution pass fixed it. (Discovered when ffi-07's stdlib-path-resolved header failed to synthesize fn decls.) - **Test-companion C reorg**. Moved `.c`/`.h` baseline helpers from `vendors/ffi_*/...` into `examples/ffi-NN-*.{c,h}` next to their `.sx`. The `vendors/` namespace stays third-party. `library/vendors/ sx_ffi_resolve_test/` keeps its place since the stdlib-search branch is precisely what it's testing. ### Open issues surfaced (filed for later) - ~~**issue-0037**~~ — fixed in 0bb7b8c (`coerceToType` + `bitcast` IR opcode now bridge ptr↔int explicitly; previously a `xx ptr` cast targeting `int` silently no-op'd, leaving the return as `ret i64 undef`). Promoted to `examples/102-foreign-global-from-helper.sx`. ## Current state - 97/97 regression tests pass (was 86 at start of FFI work; +11 net, factoring in the `examples/issue-0036.sx` → `101-ffi-medium-struct.sx` promotion). - `tests/cross_compile.sh` runs clean (empty tuple list). - Chess Android build + iOS-sim build both clean. emit_llvm.zig sret + struct↔array changes don't regress either. - ABI coverage matrix locked in for all four C-ABI aggregate slots (≤8 B int, 9..16 B int, 16 B HFA, >16 B byval+sret). ## Phase 1B complete (1.6–1.14) **`#objc_call` end-to-end across every return shape + enclosing construct.** Nine commits: | # | What | |-------|---------------------------------------------------------------------------------------| | 1.6 | `objc_msg_send` IR opcode + per-call-site LLVM function type via opaque pointers | | 1.7 | Small struct returns: 16 B HFA (NSPoint), 16 B int (NSRange), 32 B HFA (NSRect) | | 1.8 | sret transform for >16 B non-HFA returns; body-side `ret` rewrite for sx-defined IMPs | | 1.9 | UIEdgeInsets-shape 4×f64 HFA round-trip | | 1.10 | Multi-keyword selectors (`combine:and:`) — name mangling matches clang | | 1.11–13 | `#objc_call` inside struct method / protocol impl / closure body / generic fn | | 1.14 | OS-gated `inline if OS == { case }` cross-compiles cleanly to Android | Real Obj-C ABI verified via round-trip through `class_addMethod`- registered IMPs: - Triple {11, 22, 33} sret round-trip (1.8) - UIEdgeInsets {1.5, 2.5, 3.5, 4.5} HFA round-trip (1.9) - `combine(7, 42)` → 742 multi-arg (1.10) 109 host tests + 1 cross-compile target pass. Chess Android + iOS-sim builds still clean. Two findings filed: - ~~**issue-0038**~~: closure free-variable analyzer skipped `FfiIntrinsicCall` nodes. Fixed in df2ccf7; promoted to `examples/103-ffi-closure-capture.sx`. ## Phase 1D in progress (uikit.sx migration) User chose Phase 1D before Phase 1C — consume `#objc_call` in the real call sites first to flush out any cluster-shape issues before landing the parallel JNI codegen. | # | Cluster | Status | |------|----------------------------------|--------| | 1.25 | `safeAreaInsets` (UIEdgeInsets HFA, in `uikit_refresh_safe_insets`) — also drops a dead `sel_safe_insets` decl in `uikit_scene_will_connect_ios` | done (bcbf2ac) | | 1.26 | `uikit_chdir_to_bundle` — `NSBundle.mainBundle.resourcePath` chain (2× `*void` returns through one `msg_o` cast) | done (3518d0e) | | 1.27 | `uikit_read_screen_scale` — `UIScreen.mainScreen.nativeScale` (class-method `*void` + instance-method `f64`); first standalone `#objc_call(f64)` exercise | done (4844f57) | | 1.28 | `show_keyboard` / `hide_keyboard` pair — `becomeFirstResponder` / `resignFirstResponder` (BOOL returns, discarded). Initially landed as `#objc_call(u8)`; corrected to `#objc_call(bool)` in follow-up ee53348. Runtime-verified by the locked-in test `examples/ffi-objc-call-11-bool-return.sx` (e52f9f2) — two BOOL-returning IMPs via `class_addMethod`. | done | | 1.29 | `uikit_create_gl_context` — `alloc` / `initWithAPI:` / `setCurrentContext:` + duplicate of 1.27's screen-scale read | done | | 1.30 | `uikit_subscribe_keyboard_notifications` — first standalone 4-keyword selector exercise (`addObserver:selector:name:object:`) | done | | 1.31 | `uikit_scene_will_connect_ios` — biggest cluster; the iOS scene-lifecycle entry. UIWindow / UIViewController / SxGLView wiring; EAGL drawable-properties dict build; `nativeScale` + `setContentScaleFactor:` DPI path; `displayLinkWithTarget:selector:` + run-loop install. Exercises every return shape used in uikit.sx. Net -44 lines (104 → 60). | done (b3558c3) | | 1.32 | `uikit_keyboard_will_change_frame` — `userInfo` / `objectForKey:` / `CGRectValue` / `doubleValue` / `unsignedLongValue` / `screen.bounds`. First standalone exercise of `#objc_call(CGRect)` (HFA, structurally equivalent to UIEdgeInsets) and `#objc_call(u64)` (LLVM-equivalent to s64). Net -14 lines. Runtime-verified by the locked-in test `examples/ffi-objc-call-12-rect-u64-returns.sx` (ac78490). | done (e1d300c) | | 1.33 | **uikit.sx sweep — all remaining dispatch sites.** `renderbufferStorage:fromDrawable:` (bool, GL setup); `presentRenderbuffer:` (bool, every frame); `targetTimestamp` / `duration` (f64, every frame in `uikit_gl_view_tick`); `bounds` (CGRect, `uikit_compute_layer_pixel_size`); `locationInView:` (CGPoint HFA, every touch); `anyObject` (*void, every touch). First standalone `#objc_call(CGPoint)` exercise. Net -15 lines. Runtime-verified end-to-end: tapped a black pawn in iOS-sim chess and the move played correctly (1...d5, 2...d4). | done | Verification per cluster: zig build / zig test / run_examples / cross_compile all green; chess `sx build --target ios-sim main.sx` and `--target android main.sx` both compile clean. ## Phase 1C in progress (`#jni_call` codegen) Phase 1D for uikit.sx is done (`-98` lines, zero typed-cast dispatch sites). Phase 1C is now active — the JNI parallel to 1.3–1.10. The parser already accepts the syntax (step 1.2 / commit landed earlier); the work that remains is lowering + emit_llvm. | # | What | Status | |------|---------------------------------------------------------------------------------------|--------| | 1.15 | `#jni_call(void)` codegen — new `.jni_msg_send` IR opcode + emit_llvm expansion: load `*env` for the vtable, GEP into slots 31 (GetObjectClass), 33 (GetMethodID), 61 (CallVoidMethod). No method-ID caching yet; static dispatch + non-void returns drop to `LLVMGetUndef` until 1.18+. | done (134c197 xfail + 9afcaa5 fix) | | 1.16 | Lock in pre-caching IR shape — two `#jni_call` sites with literal `("noop", "()V")` emit two independent `GetMethodID` calls. IR snapshot at `tests/expected/ffi-jni-call-03-methodid-sharing.ir`. | done (13018ef) | | 1.17 | Literal-keyed slot interning — `JniMsgSend.cache_key: ?CacheKey` carries the literal `(name, sig)` pair from `lower.zig`; `emit_llvm.getOrCreateJniSlots` interns `@SX_JNI_CLS_` and `@SX_JNI_MID_` globals per unique pair; per-call lowering does null-check + lazy populate via `GetObjectClass` → `NewGlobalRef` (slot 21) → `GetMethodID` on miss. Two literal sites now share one slot pair. | done (0d883b4) | | 1.18 | `#jni_call(s32)` → CallIntMethod (vtable slot 49). One arm added to the `call_method_offset` switch; reuses the 1.17 cache. | done (1d7ea72 xfail + ebcfe4c fix) | | 1.18+ | Lift JNI vtable offsets into a `const Jni` named-constants struct. Pre-loaded Object/Boolean/Long/Float/Double slots so 1.19–1.22 are one-line switch arms. | done (c1877fc) | | 1.19 | `#jni_call(s64)` → CallLongMethod (vtable slot 52). One arm added. | done (da5b635 xfail + 5945a8c fix) | | 1.20 | `#jni_call(f64)` → CallDoubleMethod (vtable slot 58). First non-integer JNI return. | done (xfail + ca4ba75 fix) | | 1.21 | `#jni_call(bool)` → CallBooleanMethod (vtable slot 37). | done (xfail + b0e8659 fix) | | 1.22 | `#jni_call(*void)` → CallObjectMethod (vtable slot 34). Pointer-return detected via `TypeInfo.pointer | .many_pointer` ahead of the primitive switch. LocalRef cleanup deferred — chess consumes objects inline. | done (xfail + b5694cc fix) | | 1.23 | `#jni_static_call` — skip `GetObjectClass` (target IS jclass), use `GetStaticMethodID` (113) + `CallStaticMethod` family (Object 114 / Boolean 117 / Int 129 / Long 132 / Float 135 / Double 138 / Void 141). Slot interning still applies. | done (xfail + 7b566bf fix) | | 1.24 | Inverse OS gate: `examples/ffi-jni-call-02-void.sx` added to `cross_compile.sh` as `ios-sim` target. Verifies `inline if OS == .android { #jni_call(...) }` strips before lowering on iOS, so emit_llvm doesn't reach libjvm vtable slots. | done (f10daa3) | Verification per commit: zig build / zig test / run_examples / cross_compile all green. Chess iOS-sim + Android both compile clean. Host can't dlopen libjvm via the JIT, so JNI runtime correctness verification is via the Android cross-compile + on-device chess regression once Phase 1D for sx_android_jni.c lands (after this phase). ## Phase 1C complete All ten sub-steps (1.15–1.24) shipped. `#jni_call(T)` and `#jni_static_call(T)` lower to JNI vtable indirection with shared `(name, sig)` literal-keyed slot interning (one `jclass GlobalRef` + one `jmethodID` per unique pair, populated lazily on the first matching call). Return-type matrix covers `void` / `s32` / `s64` / `f64` / `bool` / `*T`. Static dispatch skips `GetObjectClass` and uses the parallel `GetStaticMethodID` + `CallStaticMethod` family. Both OS gates verified by `cross_compile.sh` (3/3 tuples green). ## Phase 1D for `sx_android_jni.c` in progress | # | Cluster | Status | |------|--------------------------------------------------------------------------------------|--------| | 1.25 | sx-side `sx_query_safe_insets_jni` in `android.sx` — reimplements the JNI dispatch chain (`getWindow → getDecorView → getRootWindowInsets → getSystemWindowInset{Top,Left,Bottom,Right}`) via `#jni_call`. Takes a pre-attached `env: *void` so the JavaVM plumbing stays in C. | done (ba0a1a1) | | 1.26 | JavaVM vtable dispatch hand-rolled in sx — `sx_load_ptr_at`, `sx_load_javavm_fn`, `sx_android_get_env(activity, out_attached)`, `sx_android_detach_env`, `sx_android_activity_clazz`. Slot indices: GetEnv=6, AttachCurrentThread=4, DetachCurrentThread=5. ANativeActivity offsets: vm=8, clazz=24 (64-bit). | done (885b423) | | 1.27 | `AndroidPlatform.safe_insets` switched from `sx_android_query_safe_insets` (C foreign) to the sx pipeline: `get_env → activity_clazz → sx_query_safe_insets_jni → detach_env`. Seven `(SX_JNI_CLS, SX_JNI_MID)` slot pairs visible in chess Android IR. | done (6e65324) | | 1.28 | On-device chess regression — APK built with `sx build --target android --apk ...`, installed via `adb install -r`, launched on Pixel device. Screencap confirms board renders with correct top inset (status bar clearance), all pieces in starting positions. Validates the full sx-side JNI stack: JavaVM env attach + 7-step dispatch chain + slot interning. | done | | 1.29 | Retired the C `sx_android_query_safe_insets` body (and its `#foreign` decl) — all dispatch now goes through sx + `#jni_call`. `` and `` includes also removed. `sx_android_install_input_handler` stays. Net -55 lines in .c, on-device chess regression re-verified. | done (4ddee93) | ## Phase 1D for sx_android_jni.c complete All five sub-steps (1.25–1.29) shipped. The safe-insets JNI chain has fully moved from C to sx: - C lines retired: ~55 (the `sx_android_query_safe_insets` body) - sx lines added: ~15 (sx_query_safe_insets_jni) + ~50 (JavaVM helpers) — net ~10 lines saved, with sx dispatch keyed through the literal-keyed slot interning from 1.17 (one `(jclass GlobalRef, jmethodID)` pair per unique method+sig, populated lazily). - On-device chess regression verified on Pixel: status bar clearance, safe-area-driven board layout, asset rendering all correct. Phase 2 (declarative JNI imports) is next major work. Phase 1 overall is functionally complete: parser + #objc_call full matrix + #jni_call full matrix + uikit.sx migration + sx_android_jni.c migration all done. ## Phase 2 in progress (type-introducer DSL) Surface form converged this session (replacing the older `#import jni "path" { ... }` block sketch): the declarative DSL uses **type-introducer directives**, parallel to `struct` / `enum` / `protocol`: ``` Foo :: #jni_class("java/path/Foo") { ...body... } Foo :: #jni_interface("java/path/IFoo"){ ...body... } Foo :: #objc_class("ObjcName") { ...body... } Foo :: #objc_protocol("ObjcProto") { ...body... } Foo :: #swift_class("Module.Type") { ...body... } Foo :: #swift_struct("Module.Type") { ...body... } Foo :: #swift_protocol("Module.Proto") { ...body... } ``` Shared body grammar across all seven forms (instance/static methods, fields for JNI, properties for Obj-C/Swift, `#extends` / `#implements` / `#selector` / `#desc` modifiers). JNI env scoping is two layered constructs: `#jni_env(env) [-> ?T] { body }` (low-level scope) and `#jni_attach(activity) [-> ?T] { body }` (high-level macro that wraps `AttachCurrentThread`). `#jni_call`'s env arg becomes optional with a TL fallback; inside `#jni_env`, lexical-direct resolution keeps the env register-resident across loops (zero TL reads on the hot path). See `current/PLAN-FFI.md::Phase 2` for the full step-by-step. | # | What | Status | |-----|---------------------------------------------------------------------------------------|--------| | 2.0 | xfail parser test: `Foo :: #jni_class("java/path/Foo") { }` (empty body, opaque). | done (4c670e6) | | 2.1 | Parser accepts `Foo :: #jni_class("path") { }` opaque form. New `hash_jni_class` token, lexer entry, `JniClassDecl` AST node (alias + java path, body deferred), `parseJniClassDecl` consuming `("...") { }` (rejects non-empty body — that's 2.2+). Sema registers the alias as `type_alias` (no body recursion). LSP classifies the directive as a keyword. Re-snapshot flips the xfail to green: `parse-only ok`, exit 0. | done (32b464e) | | 2.2 | Parser collects instance method body items. New `JniMethodDecl` AST struct (name, params, param_names, return_type — no body). `JniClassDecl.body` → `methods: []const JniMethodDecl`. parseJniClassDecl loops over body items parsing each `name :: (self: *Self, args...) -> Ret;`. Sema/lower still treat the decl as an opaque type alias. | done (f5da453 xfail + a2a2e83 fix) | | 2.3 | `static name :: (args...) -> Ret;` body items. `JniMethodDecl` gains `is_static: bool`. Body loop recognises a context-sensitive `static` identifier prefix (still a plain identifier elsewhere). | done (082ef43 xfail + ecce8cd fix) | | 2.4 | `#extends Alias;` / `#implements Alias;` body items. Two new lexer tokens `hash_extends` / `hash_implements`; `JniClassDecl.methods` refactored into `members: []const JniClassMember` tagged union (`method` / `extends` / `implements` variants); body loop dispatches on leading token. | done (e225adb xfail + a5c6f75 fix) | | 2.5 | `name: Type;` field body items. New `JniFieldDecl` struct; `JniClassMember` gains `field` variant. Body loop branches on next-after-name: `:` → field path, `::` → method path. `static` fields explicitly errored. | done (1dee9ba xfail + a703eee fix) | | 2.6 | `#jni_method_descriptor("(Sig)Ret")` per-method JNI descriptor override. New `hash_jni_method_descriptor` lexer token; method decl gains optional override field consumed after the return type. Initially proposed as `#desc`, renamed for consistency with the `#jni_*` directive family. | done (0ed4799 xfail + 11021d8 fix) | | 2.7 | Parser accepts the other six type-introducer directive forms (`#jni_interface`, `#objc_class`, `#objc_protocol`, `#swift_class`, `#swift_struct`, `#swift_protocol`) with the same body grammar. AST refactor: `JniClassDecl` → `ForeignClassDecl` carrying a `runtime: ForeignRuntime` enum discriminator; `JniMethodDecl`/`JniFieldDecl`/`JniClassMember` renamed to `Foreign*`; AST variant `jni_class_decl` → `foreign_class_decl`. `parseForeignClassDecl` takes the runtime as a parameter; `foreignRuntimeForCurrent` maps each directive token to its variant. Sema arm renamed; no codegen yet. | done (dc3821a xfail + 5fd8e0f fix) | ## Phase 2A complete (parser + AST) All seven type-introducer directive forms parse with the shared body grammar (instance/static methods, fields, `#extends`, `#implements`, `#jni_method_descriptor`). Sema registers each as an opaque type alias; no lowering yet. ## Phase 2B in progress (signature derivation) | # | What | Status | |-----|---|---| | 2.8 | `src/ir/jni_descriptor.zig` + `.test.zig`. `writeType` appends one JNI descriptor for an sx type AST node; `deriveMethod` returns the full `(args)ret` descriptor for a `ForeignMethodDecl`, skipping the implicit `self` on instance methods. `Context.enclosing_path` resolves `*Self` to its `L;` form. Primitive table-driven (void→V, bool→Z, s8/u8→B, s16→S, u16→C, s32→I, s64→J, f32→F, f64→D); arrays `[]T`/`[*]T`/`[N]T` → `[`. Cross-class `*Foo` → explicit error (lands in 2.9). 10 unit tests pass. **Cadence note**: landed as single commit since internal compiler functions don't have a sx-level snapshot surface yet — the rule re-applies at 2.11 where call-site lowering becomes end-to-end observable. | done (21c4906) | | 2.9 | Cross-class `*Foo` resolves via `Context.classes: ?*const ClassRegistry` (a `StringHashMap` of sx alias → foreign path). `*Self` and `*Foo` share one code path. Retired `CrossClassRefNotYetSupported` in favour of `UnknownClassAlias`, which fires for both "no registry provided" and "alias not in registry". | done (5188265) | | 2.10 | `deriveMethod` short-circuits to the `jni_descriptor_override` (2.6 escape-hatch) when present, returning the override verbatim through an `allocator.dupe`. Bypasses normal derivation entirely — including resolution failures, which lets users escape `UnknownClassAlias` errors for synthetic-method cases. | done (ca840ff) | ## Phase 2B complete (signature derivation) `src/ir/jni_descriptor.zig` handles every shape the parser can hand it: - Primitive types: `void/bool/s8..s64/u8/u16/f32/f64` → JNI single-char. - Arrays / slices / many-pointers: `[` (recursive). - `*Self` → `L;`. - `*Foo` → looks up Foo's foreign path in the supplied registry. - `#jni_method_descriptor("...")` override → returns the literal verbatim. - Cross-class miss / no-registry → `UnknownClassAlias`. 15 unit tests cover the matrix. Function is ready for consumption by call-site lowering (step 2.11+). ## Phase 2 step 2.16 in progress (env scoping) | # | What | Status | |-------|---|---| | 2.16a | Parser + AST + sema accept `#jni_env(env) { body }`. New `hash_jni_env` lexer token; `parsePrimary` dispatches to `parseJniEnvBlock`. AST node `JniEnvBlock { env, body }`. Sema's `analyzeNode` and `findNodeAtOffset` arms recurse through env + body. Lowering treats it as a syntactic wrapper around the block (env evaluated for side effects, body lowers normally). `expectSemicolonAfter` recognises it as block-form so no trailing `;` needed. | done (93adde5 xfail + 5bd2c84 fix) | | 2.16b | Lexical-direct env resolution + optional env in `#jni_call`. `Lowering` gains a `jni_env_stack: ArrayList(Ref)`; the `jni_env_block` arm pushes/pops around body lowering. `lowerJniCall` disambiguates via position of first string-literal arg (index 1 = omitted, index 2 = explicit) and reads top of stack when omitted. IR snapshot locks in the optimised shape (env flows straight from enclosing scope into `jni_msg_send`; no TL read). | done (e463385 xfail + 022ca31 fix) | | 2.16c | TL fallback for cross-function helpers — the env scope's value is also written to a thread-local slot, so callees outside the lexical scope read it via `sx_jni_env_tl_get()`. Also collapses `#jni_call` to always-omit-env (explicit-env shape retired). Per-fn `jni_env_stack_base` makes lazy-lowered callees ignore the caller's Refs. **Note**: the TL slot currently lives in a small C runtime helper (`library/vendors/sx_jni_runtime/sx_jni_env_tl.c`) because LLVM ORC JIT's default platform doesn't initialise TLS for objects loaded via `LLVMOrcLLJITAddObjectFile`. The .c file is linked into sx-the-compiler (build.zig) and auto-injected into AOT outputs (`lowering_extra_c_sources` on Compilation). **This is a stopgap** — see "Deferred: sx-native runtime" below. | done (013cf9f xfail + 6a3260f fix) | ## Phase 2C in progress (call-site lowering) | # | What | Status | |-------|---|---| | 2.11 xfail | `act.getWindow()` on a `#jni_class`-typed receiver. Today's sema reports "unresolved: 'getWindow'" because foreign-class members aren't wired into method resolution. Snapshot captured. | done (09e4ec2) | | 2.11 green | `Lowering` gains `foreign_class_map`; `registerForeignClassDecl` records each alias in the scan pass. `lowerCall`'s method-dispatch arm now checks for foreign-class receivers BEFORE the standard struct-method path, calling new `lowerForeignMethodCall`. That helper looks up the method in `ForeignClassDecl.members`, derives the descriptor via `jni_descriptor.deriveMethod` (with a `ClassRegistry` snapshot of all foreign classes), and emits `jni_msg_send` directly. Env from the enclosing `#jni_env` scope (lexical-direct). Filters by runtime — `jni_class`/`jni_interface` lower; Obj-C/Swift report "not yet supported". The type-bridge's existing fallback (unknown named types → 0-field struct) handles `*Activity` resolution with no additional plumbing. `jni_descriptor` gains `*void → Ljava/lang/Object;` (opaque-jobject default). IR snapshot at `ffi-jni-class-08-call.ir` shows the full slot-interned lowering. | done (2882748) | ## Phase 2C complete (call-site lowering) The DSL is end-to-end live. A user can declare a `#jni_class`, write `inst.method(args)` inside a `#jni_env(env) { }` scope, and the compiler synthesizes the full JNI dispatch — descriptor derivation, slot interning, env passing, all of it. ## Phase 2D in progress (migration) | # | What | Status | |-------|---|---| | 2D.1 | `library/modules/platform/android.sx` `sx_query_safe_insets_jni` migrated to declarative `#jni_class` blocks (first attempt). Four foreign-class declarations at android.sx top level, body uses `#jni_env(env) { ... }` with `inst.method(...)` calls. Verified macOS host + cross_compile but FAILED on chess build because `View` collided with `modules/ui/view.sx`'s `View` protocol — `imports.zig::addDecl` dropped android's `View` foreign_class on duplicate-name. | done (c9db2a8) | | 2D.2 | Bare-name collision fixed via named-import sub-module pattern. The four `#jni_class` decls move into new `library/modules/platform/android_jni.sx`; android.sx imports them under `Jni :: #import "..."`. Receiver types use `*Jni.Activity` etc. Compiler-side: `scanDecls`/`lowerDecls` register foreign-class decls under both qualified (`Jni.Activity`) AND bare (`Activity`) names — qualified for receivers, bare for cross-class refs in method sigs. **On-device verified**: chess APK installed on Pixel, board renders with correct status-bar clearance, no crashes in logcat. Safe insets queried via the new declarative dispatch produce the same values as the pre-migration hand-rolled #jni_call chain. | done (60f3ffe) | ## Phase 2 functionally complete Every JNI call site in `library/modules/platform/android.sx` now flows through `#jni_class` + `#jni_env` + `inst.method(...)`. Descriptor strings are gone from the dispatch chain — derivation happens at lower time via `jni_descriptor.zig`. The hot-path optimisation (lexical-direct env from 2.16b) keeps env register-resident across the safe-insets chain. On-device chess verified. Remaining: `sx_android_install_input_handler` is the last entry in `library/vendors/sx_android_jni/sx_android_jni.c`. It's not JNI dispatch (it's struct-field plumbing on `ANativeActivityCallbacks`) so the DSL doesn't apply. The file can stay until/unless we add a separate plain-C ceremony reduction track. ## Surface: define-by-default + #foreign modifier + #jni_main (8d18160) `Foo :: #jni_class("...") { ... }` now means "DEFINE a new Java class at this path." `#foreign` flips it back to "reference an existing class." `#jni_main` marks the launchable Activity. The model generalises to `#objc_class`, `#swift_class`, etc. ``` Foo :: #foreign #jni_class("path") { ... } // reference (most chess usage) Foo :: #jni_class("path") { ... } // define (runtime synth deferred) Foo :: #jni_main #jni_class("path") { ... } // define + main Activity ``` Bodied methods inside a defined class are sx-side implementations; `;`-terminated methods reference inherited / external impls. Foreign classes stay `;`-only. ## `#jni_main` pipeline in progress Driving the defined-class path end-to-end. The eventual user-facing goal: a `#jni_main #jni_class("...") { ... }` decl replaces chess's `android_main(app)` boilerplate with a declarative Activity. Split into slices so each lands incrementally. | # | What | Status | |-------|---|---| | jm.1 | Pure Java source emission in `src/ir/jni_java_emit.zig`. `emitJavaSource(allocator, fcd, opts) -> []u8` produces the `package ...; public class ... extends ... { @Override + private native sx_(...); }` skeleton from a `ForeignClassDecl`. Six unit tests in `jni_java_emit.test.zig` lock the type matrix (void / primitives / `*void` → Object / cross-class refs via the supplied registry / `#extends` resolution / default package). | done (7ea7ad7) | | jm.2 | AOT pipeline integration. `Compilation.lowering_jni_main_decls` is populated by `lowerToIR` (iterating `foreign_class_map` for `is_main && !is_foreign && runtime==jni_class`, deduped by `foreign_path`); each entry carries the pre-rendered Java source. `createApk` now (when the list is non-empty) writes `/java//.java`, invokes `javac --release 11 -classpath ` to `/classes/`, invokes `d8 --release --lib --output ` to produce `/classes.dex`, and zips the .dex into the unaligned APK at root. Manifest still hardcodes `android.app.NativeActivity` (slice jm.3) so the .dex is bundled but unreferenced at runtime. `javac` discovery: `$JAVA_HOME/bin/javac` → `which javac` → diagnostic. | done | End-to-end verified by APK inspection: `dexdump -l plain` on the sample APK shows `Lco/swipelab/sxjnimain/SxApp;` extending `Landroid/app/NativeActivity;`. Non-`#jni_main` builds (`99-android-egl-clear.sx`) produce the same APK shape as before (no classes.dex, plain NativeActivity manifest). Remaining slices for the pipeline: - **jm.3** — manifest synthesis sets `` to the user's class + `android:hasCode="true"`. - **jm.4** — lower emits a synthetic `JNI_OnLoad` that calls `RegisterNatives` to bind the `sx_` symbols to sx-side fns. Bodied methods inside `#jni_main` decls are no-ops in lower today; this slice turns them into real native functions. - **jm.5** — ship `modules/runtime/jni/native_activity.sx` so users override individual lifecycle methods on a stdlib-provided Activity rather than declaring their own from scratch. - **jm.exclusive** — sema enforces exactly one `#jni_main` decl per program. ## Deferred: sx-native runtime (replaces C-helper TLS from 2.16c) The current C runtime helper at `library/vendors/sx_jni_runtime/sx_jni_env_tl.c` is a stopgap. sx is planned to be a fully cross-target pipeline, so runtime helpers like this should be written in sx itself — not relegated to C for linkage-pipeline reasons. The C file exists because: 1. sx's IR module *can* declare `thread_local` globals, and they work in AOT (platform linker handles TLS via dyld / bionic / etc). 2. But LLVM ORC JIT's default `LLVMOrcCreateLLJIT(&jit, null)` ships no "platform" plugin to allocate TLS slots for objects added via `LLVMOrcLLJITAddObjectFile`. A `thread_local` global in the user IR module → crash at module load. 3. Keeping the storage out of the user's IR sidesteps that — the C helper lives in the *host* process (sx-the-compiler), which got its TLS slots from dyld at startup, independent of LLVM. 4. The .c file ALSO needs to link into AOT binaries (chess's `.so`). The existing `#import c { #source ... }` pipeline carries C sources through every codegen path; an sx-side runtime today would need a new cross-target build pipeline (which sx will eventually have, but doesn't yet). When sx grows the cross-target story far enough: | Step | What | |---------------------|---| | sx `#threadlocal` | Add a `#threadlocal` modifier on top-level globals (parser → AST → lower → `Global.is_thread_local`). emit_llvm already flips `LLVMSetThreadLocal` on that flag. Test with a non-JNI thread-local to lock the surface in. Decouples from the JNI work — lands as its own thing. | | JIT TLS support | Either ship `orc_rt.dylib` and configure `LLJITBuilder` with `ExecutorNativePlatform` (C++ shim, version-locked to LLVM), OR keep the runtime-in-host model but rewrite the helper as an sx file that gets cross-compiled like any other sx module AND linked into sx-the-compiler. The latter aligns better with the sx-everywhere goal. | | Drop the .c file | Rewrite `sx_jni_env_tl_get`/`_set` in sx. Drop the `addCSourceFile` call from `build.zig`. Drop the `lowering_extra_c_sources` auto-injection (or keep it for other potential runtime helpers). Lower's lazy-extern declaration becomes a sx-resolved fn reference. No surface or test changes. | ## Next step `#jni_main` shipped (manifest synth jm.3, RegisterNatives jm.4, asset forwarding, R.1–R.5 retiring the legacy NativeActivity surface — all landed; chess on Pixel runs end-to-end as the integration witness). JNI return + parameter type validation lives in lowering with source- spanned diagnostics; CallMethod coverage spans bool / s8 / s16 / u16 / s32 / s64 / f32 / f64 / pointer; varargs promotion is wired. Phase 3 step 3.0 landed (for real this time): `inst.method(args)` on an `#objc_class` / `#objc_protocol` receiver derives the selector via default mangling (niladic → name verbatim; arity ≥ 1 → split on `_`, each piece becomes a keyword with a trailing `:`) and lowers to `objc_msg_send` against the cached SEL slot. Arity mismatches diagnose at the call site with a remediation hint pointing at `#selector(...)` override (3.2). New helpers `deriveObjcSelector` and `lowerObjcMethodCall` at [lower.zig](../src/ir/lower.zig). Tests: `examples/ffi-objc-dsl-{01-niladic,02-one-arg,03-multi-keyword,04-mismatch}.sx` — landed previously as xfail-with-diagnostic, snapshots now flipped to working output (and the mismatch case to the specific keyword-count error). Phase 3 step 3.1 landed: `Cls.static_method(args)` on an `#objc_class` alias loads the class object through a module-scoped cached slot (`OBJC_CLASSLIST_REFERENCES_`, populated once per module via `objc_getClass` at module-init) and dispatches `objc_msg_send` with the same selector derivation as 3.0. New `Module.objc_class_cache` parallel to `objc_selector_cache`; `internObjcClassObject` and `lowerObjcStaticCall` helpers in lower.zig; `emitObjcClassInit` constructor in emit_llvm.zig that walks the cache, runs `objc_getClass` per slot, registers via `@llvm.global_ctors`, and injects a direct call into `main` for the ORC JIT path. Surface form is `.` (matching JNI's `Alias.new(...)` convention) rather than the plan's notional `::` — avoids a new postfix operator. Test: `examples/ffi-objc-dsl-05-static.sx` — exercises NSObject's `+class` and `+description` class methods (NSObject is always available at module-load, unlike test classes created in main's body). Open work, in roughly the order they make sense: - **Phase 3 step 3.2** — `#selector("explicit:")` override + golden test for the default-mangling table. Escape hatch for selectors that don't fit the underscore-split rule (e.g. `tableView_ numberOfRowsInSection_` with an asymmetric keyword count). - **Phase 3 step 3.3** — `property name: Type` synthesizes `inst.name` → `[inst name]` getter and `inst.name = x` → `[inst setName: x]` setter. `#setter("...")` overrides the setter selector. - **Phase 3 step 3.4–3.6** — `#extends`, foreign type aliases (`id` / `Class` / `SEL` / `BOOL` / `instancetype` / `_Nullable T`), `static new :: (args...) -> *Self;` synthesizing `[[Class alloc] init...]` chains. - **Phase 3 step 3.7** — `impl ObjcProtoAlias for SxType` synthesizes a runtime Obj-C class via `objc_allocateClassPair` / `class_addMethod` / `class_addProtocol` / `objc_registerClassPair`. Replaces the hand-written `uikit_register_classes` body in `library/modules/platform/uikit.sx`. - **Phase 3 step 3.8** — uikit.sx migration: retire every `objc_getClass` lookup + hand-written class registration in favor of the `#objc_class` / `impl Protocol for ...` surface that 3.0–3.7 ship. After Phase 3: - **`#jni_main` slice jm.5** — stdlib base class `library/modules/runtime/jni/native_activity.sx` so consumers override individual lifecycle methods on a stdlib-provided Activity instead of writing the AndroidPlatform plumbing from scratch. Concrete payoff: chess's SxApp shrinks ~70 lines. - **Phase 4** — Swift bridge. - **Phase 5** — `#import jni auto { classpath ... }` synthesizes `#jni_class` decls from .jar bytecode. Cadence-rule reminder (each commit either locks in current behavior with a passing test OR turns an xfail green — never both in one commit): ```sh zig build && zig build test && bash tests/run_examples.sh && bash tests/cross_compile.sh ``` ## Log - 2026-05-19: Plan written, committed at `current/PLAN-FFI.md`. - 2026-05-19: Steps 0.0–0.2 done (primitives, small-struct baselines). - 2026-05-19: issue-0036 fixed via emit_llvm coerceArg struct↔array bridges. - 2026-05-19: Steps 0.2 (folded back) – 0.3 done. >16 B sret return transform added to emit_llvm.zig. - 2026-05-19: Steps 0.4–0.6 done (FP-aggregate, strings, callbacks). - 2026-05-19: Step 0.7 done; imports.zig stale-ci fix landed alongside. - 2026-05-19: Test C helpers reorg — `examples/ffi-NN-*.{c,h}` next to the `.sx`. `vendors/ffi_*/` removed. - 2026-05-19: Steps 0.8, 0.9 done (constructs-around-FFI, handle chains). - 2026-05-19: Step 0.10 done; issue-0037 (`@foreign_global` from helper fn → undef) filed. - **Phase 0 complete. 97/97 regression tests pass. Chess Android + iOS-sim both build clean.** - 2026-05-19: Phase 1.0–1.5 done. `#objc_call(void)` works end-to-end with clang-shape selector interning. 101/101 regression tests pass; IR-snapshot harness added; `tests/expected/.ir` snapshots catch lowering changes invisible in runtime output. - 2026-05-19: Phase 1.6–1.14 done (all return shapes + enclosing constructs). 109 host + 1 cross-compile target green. - 2026-05-19: issue-0037 fixed (ptr↔int in `coerceToType` + `bitcast`); test promoted to `examples/102-foreign-global-from-helper.sx`. issue-0038 (closure free-var analysis skips `FfiIntrinsicCall`) still open. - 2026-05-19: Phase 1D cluster 1.25 done — `uikit_refresh_safe_insets` migrated to `#objc_call(UIEdgeInsets)(plat.gl_view, "safeAreaInsets")`; dead `sel_safe_insets` decl dropped from `uikit_scene_will_connect_ios`. Net -3 lines. Chess iOS-sim + Android still compile clean. Committed as bcbf2ac. iOS-sim chess: board renders with correct status-bar clearance. - 2026-05-19: Phase 1D cluster 1.26 done — `uikit_chdir_to_bundle` migrated to two `#objc_call(*void)` calls (`mainBundle` class method + `resourcePath` instance method). Net -3 lines. iOS-sim chess: app loads with all piece assets rendered (proves `chdir` to the bundle resource path still succeeds). - 2026-05-19: Phase 1D cluster 1.27 done — `uikit_read_screen_scale` via `#objc_call(*void)` + `#objc_call(f64)`. First standalone `#objc_call(f64)` exercise; previously only covered indirectly by the UIEdgeInsets 4×f64 HFA test. Net -4 lines. iOS-sim chess: input hit-testing + sharp rendering confirms `dpi_scale` is correct. - 2026-05-19: Phase 1D clusters 1.28–1.30 done in one batch commit (65643fb). `show_keyboard` / `hide_keyboard` (u8 returns, compile-only — chess startup doesn't reach them); `uikit_create_gl_context` (alloc, initWithAPI:, setCurrentContext: + the screen-scale dup from 1.27); `uikit_subscribe_keyboard_notifications` (first standalone 4-keyword selector). Net -15 lines on this commit. uikit.sx now 912 lines (started at 937 → -25 cumulative across Phase 1D so far). iOS-sim chess launches cleanly. - 2026-05-19: 1.28 follow-up (ee53348) — `#objc_call(u8)` → `#objc_call(bool)` on the keyboard pair, matching Apple's documented BOOL return type. - 2026-05-19: 1.28 backfill (e52f9f2) — wrote `examples/ffi-objc-call-11-bool-return.sx` to lock in `#objc_call(bool)` against two `class_addMethod`-registered IMPs. 110/110 host tests pass. - 2026-05-19: Phase 1D cluster 1.31 done — the big one, `uikit_scene_will_connect_ios`. Touches every return shape used in uikit.sx in one launch path. Net -44 lines on this commit; also dropped a stale `EAGLContext := objc_getClass(...)` decl that wasn't used in this function. uikit.sx now 868 lines (started at 937 → -69 cumulative across Phase 1D). iOS-sim chess launches cleanly through the whole migrated path: window/VC/GL view wiring, EAGL drawable dict, DPI scaling, display link install. - 2026-05-19: Phase 1D cluster 1.32 done — `uikit_keyboard_will_change_frame` (the keyboard notification callback). First standalone `#objc_call(CGRect)` and `#objc_call(u64)` exercises. Net -14 lines. Compile + launch clean; function body isn't reached by chess startup so runtime exercise is transitive only. - 2026-05-19: Phase 1D cluster 1.33 done — sweep of all remaining dispatch sites in uikit.sx: `renderbufferStorage:fromDrawable:`, `presentRenderbuffer:`, `targetTimestamp`/`duration` per-frame reads, layer `bounds`, touch `locationInView:` (first `#objc_call(CGPoint)` exercise), `anyObject`. Net -15 lines. Runtime-verified end-to-end: tapped a pawn in iOS-sim chess and the move played correctly. - **Phase 1D for uikit.sx complete.** Zero `xx objc_msgSend` typed casts remain. uikit.sx 839 lines (937 → -98). - 2026-05-19: **Phase 1C started.** Step 1.15 done (134c197 xfail + 9afcaa5 fix). New `.jni_msg_send` IR opcode + emit_llvm expansion for `#jni_call(void)` instance dispatch. Vtable indirection: load `*env`, GEP into slots 31 (GetObjectClass) / 33 (GetMethodID) / 61 (CallVoidMethod), call each. String slices auto-extracted to raw `ptr` via new `extractSlicePtr` helper. Static dispatch + non-void returns drop to `LLVMGetUndef` (next steps wire them). Android cross-compile passes for `examples/ffi-jni-call-02-void.sx`. Host 112/112 + cross 2/2 + chess both targets clean. - 2026-05-19: Phase 1C step 1.16 done (13018ef). Added `examples/ffi-jni-call-03-methodid-sharing.sx` with two `#jni_call` sites against literal `("noop", "()V")`; IR snapshot locks in today's two-GetMethodID-call shape. Runtime is a no-op — `unused_jni` reachable through a runtime-readable `g_should_call` global so the function body survives constant-fold but the dereferences never execute. - 2026-05-19: Phase 1C step 1.17 done (0d883b4). Literal-keyed slot interning: `JniMsgSend.cache_key` carries the literal `(name, sig)` from lower.zig; emit_llvm interns `@SX_JNI_CLS_` and `@SX_JNI_MID_` per unique pair, populated lazily on first call (GetObjectClass → NewGlobalRef → GetMethodID, branch-and-phi per site). Two literal sites now share one slot pair. Snapshot at `tests/expected/ffi-jni-call-03-methodid-sharing.ir` updated. - 2026-05-19: issue-0038 closed (35359b8 xfail + df2ccf7 fix). `collectCaptures` in `src/ir/lower.zig` now has the missing `.ffi_intrinsic_call` arm — closure free-variable analysis walks `return_type` + every `args[i]`. `examples/issue-0038.sx` renamed to `examples/103-ffi-closure-capture.sx`. Workaround in `examples/ffi-objc-call-09-in-construct.sx` (module-global `g_hasher_recv`) removed; closure now captures `recv` from its enclosing fn arg list normally. - 2026-05-19: 1.32 backfill (ac78490) — wrote `examples/ffi-objc-call-12-rect-u64-returns.sx`. Locks in `#objc_call(CGRect)` (4×f64 HFA) and `#objc_call(u64)` against two `class_addMethod`-registered IMPs. 111/111 host tests pass. No outstanding FFI verification gaps. - 2026-05-20: `#jni_main` slice jm.2 done — AOT pipeline integration. `Compilation.lowering_jni_main_decls` populated by `lowerToIR`, `createApk` extended with `javac` + `d8` + classes.dex zip step. Smoke at `examples/ffi-jni-main-01-emit.sx` (added to `cross_compile.sh` as android tuple). 131 host + 4 cross tests green. Manual APK inspection: `dexdump -l plain` shows `Lco/swipelab/sxjnimain/SxApp;` extending NativeActivity in `classes.dex`. EGL demo APK still bundles without a .dex (no regression on the no-`#jni_main` path). - 2026-05-20: issue-0042 closed — top-level `inline if OS == .x { ... }` now strips the unmatched arm before import resolution and decl scanning, so a `#jni_main` Activity (or any other decl, including `#import`) wrapped in the gate is visible on the matching target and invisible everywhere else. New `flattenComptimeConditionals` pass in `imports.zig` runs at the head of `resolveImports`, walking `OS` / `ARCH` / `POINTER_SIZE` against the current target's enum variant; nested forms (`inline if X { inline if Y { ... } }`) are recursed into. `parseStmt` learned to accept `#import` / `#framework` inside `inline if` bodies (the parser doesn't know the enclosing context at parse time — the flatten pass is the only place that surfaces them). issue-0042 promoted to `examples/107-top-level-inline-if-os-gate.sx`; companion `examples/108-top-level-inline-if-imports.sx` + two helpers exercise the per-arm `#import` path (host arms pull `gated_label => 1` from one helper, else arm pulls `gated_label => 2` from the other). 138 host + 8 cross tests green. - 2026-05-20: issue-0044 fixed — `#jni_main` method bodies couldn't call deferred-type-fns (e.g. `format(...)` → `any_to_string`). `Lowering.lowerRoot` ran `lowerDeferredTypeFns` (Pass 3) before `synthesizeJniMainStubs` (Pass 5), so any deferral queued while lowering JNI stub bodies stayed undrained — the callee stayed an extern C-ABI stub (`string → ptr`) while every sx-side call kept the native `{ ptr, i64 }` shape, and LLVM verification rejected the module. Swapped the pass order so JNI stub lowering happens BEFORE the deferred drain. Chess-on-Pixel surface (corrupted `Platform.begin_frame() -> FrameContext` at the protocol-vtable boundary) was the visible flush of the same lazy-lowering ordering bug. issue-0044 promoted to two focused tests: `examples/109-jni-main-deferred-fn.sx` pins the cross-compile regression (compile-only Android), and `examples/111-protocol-vtable-sret-mixed-struct.sx` locks in the chess-on-Pixel runtime surface (vtable-dispatched protocol method returning an AAPCS-sret mixed struct). 141 host + 9 cross tests green. - 2026-05-20: silent-`undef` fallback sweep in `src/ir/emit_llvm.zig`. ~25 sites where IR opcodes / map lookups / type-kind guards used to silently `LLVMGetUndef(...)` on a "shouldn't happen" path now emit a proper compiler error via the newly-wired `diagnostics: ?*errors.DiagnosticList` field (Compilation.generateCode sets it before `emit()`). Covers JNI msg_send return-type switches (instance / static / nonvirtual), map lookups (`global_get` / `_addr`, `func_ref`, `call` callee, `closure_create`), type-kind guards (`load`, `struct_get`, `struct_gep`, `enum_payload`, `union_gep`, `index_get`, `index_gep`, `length`, `data_ptr`, `subslice`, `array_to_slice`, `call_closure`, `unbox_any`), stub IR opcodes (`context_load/store/save/restore`, `protocol_call_dynamic`, `protocol_erase`, `compiler_call`, the `.out` builtin `else` arm), and the four `getRefIRType(arg_ref) orelse .void` per-arg defaults (objc + 3 jni paths). Each site still maps an undef so emission can continue and the build aborts at the next hasErrors() check. Diagnostics here are span-less; lowering is the right place to attach spans (see next entry). Left alone: `.const_undef` (legitimate `---`), LLVMInsertValue builder seeds, and the ret-coerce fallback at emit_llvm.zig:1681 ( load-bearing for LLVM verification of dead comptime code paths). 141 host + 9 cross tests green. - 2026-05-22: issue-0043 closed — `#foreign` C-variadic tail. Trailing `args: ..T` on a foreign declaration maps to the C calling convention's `...` instead of sx's slice-packing path. `declareFunction` ([src/ir/lower.zig:671](src/ir/lower.zig#L671)) drops the variadic param from the IR signature and sets `Function.is_variadic`; `emitFunctionDecl` ([src/ir/emit_llvm.zig:682](src/ir/emit_llvm.zig#L682)) passes `is_var_arg=1` to `LLVMFunctionType` accordingly. New `promoteCVariadicArgs` applies C default argument promotion (`bool/s8/s16/u8/u16 → s32`, `f32 → f64`) to extras past the fixed param count. `packVariadicCallArgs` early-outs for foreign+variadic so the slice-packing path is bypassed entirely. New test `examples/ffi-foreign-cvariadic.sx` + `.c` exercise s64 / f64 / s32 returns through C `va_arg` over s32 / f64 / `*u8` element types. Stale-snapshot drift from in-progress std.sx additions (`xml_escape`, `path_join`, `BuildOptions.set_post_link_*`) re-pinned in 12 expected files — verified all diffs were dead-decl additions, string slot renumbering, or the UB-driven `08-types` struct field (test reads `u8 = ---` without setting it first). 150 host + 10 cross tests green. - 2026-05-21: Phase 3 step 3.0 — `inst.method(args)` DSL dispatch on `#objc_class` / `#objc_protocol` receivers now lowers cleanly. `lowerForeignMethodCall` branches on `fcd.runtime`: Obj-C runtimes route through the new `lowerObjcMethodDispatch` helper, JNI runtimes keep the existing path, Swift stays deferred. Default selector mangling lives in `deriveObjcSelector`: niladic methods use the sx-side name verbatim (`length` → `length`); arity-N methods split on `_`, each piece becomes a keyword with a trailing `:`, and the number of pieces must equal arity (excluding self). Examples: `addObject(o)` → `addObject:`, `insertObject_atIndex (o, i)` → `insertObject:atIndex:`. Mismatches diagnose at the call site with `Obj-C method 'X': default selector mangling expects N underscore-separated keyword(s) to match arity N (use '#selector(\"...\")' to override)`. The override syntax itself is slice 3.2. Reuses the existing `internObjcSelector` + `objc_msg_send` IR opcodes, so the emit path is unchanged — selectors get `@OBJC_SELECTOR_REFERENCES_` slots populated by the module-load constructor matching clang's lowering shape byte-for-byte. Four new tests: `examples/ffi-objc-dsl-01-niladic.sx` (NSObject.init), `examples/ffi-objc-dsl-02-one-arg.sx` (NSMutableArray.addObject), `examples/ffi-objc-dsl-03-multi-keyword.sx` (insertObject_atIndex → insertObject:atIndex:), `examples/ffi-objc-dsl-04-mismatch.sx` (the diagnostic). 148 host + 10 cross tests green; chess on Pixel still commits e2→e4 clean. - 2026-05-20: JNI CallMethod coverage extended to the small numeric types + C-varargs promotion at the call site, closing the gap the param-validator opened. Three new vtable rows in emit_llvm: instance : CallByteMethod=40 / CallCharMethod=43 / CallShortMethod=46 nonvirt : CallNonvirtualByteMethod=70 / Char=73 / Short=76 static : CallStaticByteMethod=120 / Char=123 / Short=126 Each variant's `.jni_msg_send` return-type switch grew rows for `.s8` / `.s16` / `.u16` (jbyte / jshort / jchar). New `LLVMEmitter.jniPromoteVararg(val, raw_ty)` handles the call-site promotion that JNI's variadic CallMethod runtime expects: s8 / s16 → SExt to i32 u8 / u16 / bool → ZExt to i32 f32 → FPExt to f64 Pointers and wide types pass through unchanged. Wired into all three arg-loop sites (instance, nonvirtual, constructor — emit_llvm.zig). `Lowering.validateJniType` relaxed to accept the newly-supported set: `.signed`{8,16,32,64} / `.unsigned`{8,16} / bool / f32 / f64 / pointer / void (for returns only). Also reordered `lowerForeignMethodCall` so signature validation runs BEFORE `deriveMethod` — otherwise the descriptor derivation's `UnknownPrimitive` error fires first with the call-site span, hiding the more useful "unsupported return/parameter type at this token" diagnostic. New cross-compile test `examples/114-jni-promoted-narrow-types.sx` exercises a `#jni_class` returning `s8 / s16 / u16` and a varargs method taking `(s8, s16, u16, f32)`; IR shows the expected `sext i8 → i32`, `sext i16 → i32`, `zext i16 → i32`, and `double 1.5e+00` (FPExt folded for the constant) at the call site. Tests 112 / 113 migrated to use `u32` (Java has no unsigned 32-bit type, so it remains unsupported) for a still- valid negative-case repro. 144 host + 10 cross tests green; chess on Pixel still commits e2→e4 clean. - 2026-05-20: JNI parameter / argument validation lifted into the same lowering helpers. `lowerForeignMethodCall` now iterates `method.params` (skipping the implicit `*Self` for instance methods) and rejects unsupported parameter types at the type token's span; `lowerJniCall` validates each method arg's TypeId post-lowering against the arg expression's span. Same supported set as returns (bool / s32 / s64 / f32 / f64 / pointer) minus `void` for params. Refactor splits `validateJniReturnType` / `validateJniParamType` over a shared `validateJniType` core that formats the diagnostic with a "return type" / "parameter type" slot label. Note in code that JNI C-varargs promotion (jbyte/jshort/jchar → jint, jfloat → jdouble) is missing in emit_llvm, so even though those types are valid JNI args in principle, lowering keeps them out of the supported set until promotion lands — otherwise the runtime ABI mismatch would silently shred the call. New focused test `examples/113-jni-unsupported-param-type.sx` locks in the parameter-type diagnostic shape (e.g. `examples/113-jni-unsupported-param-type.sx:16:30: error: JNI call 'Foo.take': unsupported parameter type 's8' (...)`). 143 host + 9 cross tests green; chess on Pixel still builds clean. - 2026-05-20: JNI return-type validation lifted from emit_llvm into the lowering pass (`lowerJniCall` + `lowerForeignMethodCall` in `src/ir/lower.zig`) so the diagnostic carries the return-type slot's source span. New `Lowering.validateJniReturnType` helper mirrors the supported set in emit_llvm's `.jni_msg_send` switch (void / bool / s32 / s64 / f32 / f64 / pointer types); a `*Foo.bad()` call where `bad()` returns an unsupported type now produces e.g. `examples/112-jni-unsupported-return-type.sx:15:29: error: JNI call 'Foo.bad': unsupported return type 's8' (JNI lowering supports ...)`. emit_llvm's diagnostic stays as defense in depth — it would only fire if a future IR path bypasses the lowering check. New focused test `examples/112-jni-unsupported-return-type.sx` locks in the diagnostic shape. 142 host + 9 cross tests green; chess on Pixel still runs end-to-end (supported types pass the check cleanly). - 2026-05-20: chess-on-Pixel touch input fixed in `src/ir/emit_llvm.zig`. The JNI `CallMethod` return-type switch (instance / static / nonvirtual) was missing `.f32`, so `#jni_class` methods like `MotionEvent.getX/getY()` lowered to `LLVMGetUndef(...)` and never actually invoked the Java method — garbage f32 values flowed through chess's touch pipeline, hit `ScrollView.handle_event`'s `frame.contains(pos)` as NaN, and were silently rejected. Added `.f32 => Jni.CallFloatMethod` plus the static / nonvirtual parallels (the matching `Jni.Call[Static| Nonvirtual]FloatMethod` constants were already defined at emit_llvm.zig:50, 62, 74; only the switch rows were missing). Same edit replaced the bare `else => { undef; return; }` arms in all three switches with `std.debug.panic("JNI {variant} call: unsupported return type {s}", .{@tagName(ret_ty_id)})` so any future missing row crashes the compiler loudly instead of shipping `undef` to the device. Follow-up: lift the unsupported- type detection into the lowering pass for a proper diagnostic with a source span. Verified end-to-end on Pixel 7 Pro: tap-to- select highlights the pawn (yellow) with green dots on valid targets; tap-target commits the move (e2→e4 verified, info panel shows "Black to move" / "1. e4"). 141 host + 9 cross tests green. - 2026-05-20: chess-on-Pixel size bug fixed by refactoring `library/modules/platform/android.sx` to zero module-level globals. Root cause: android.sx exported `g_viewport_w : s32 = 0` and `g_viewport_h : s32 = 0` at module scope; chess's `main.sx` declared its own `g_viewport_w : f32 = 800.0` at module scope. When chess `#import`ed android.sx, the imported public global shadowed chess's local decl for the unqualified name resolution, so chess's writes (`g_viewport_w = fc.viewport_w`) silently clobbered android.sx's s32 with the logical f32 cast to s32 (414 instead of 1080). `Gles3Gpu.pixel_w` then fed `glViewport(0,0, 414,831)`, clipping rendering to a 414-pixel box in the GL- bottom-left. Refactor moved every piece of Android backend state (app_window, EGL handles, viewport dims, render thread, frame closure, touch ring + mutex, user_main_fn) onto AndroidPlatform struct fields. All `sx_android_*` helpers now take `plat: *AndroidPlatform` as their first arg; render thread entry reads `plat` via `pthread_create`'s `arg`. Consumer (chess) stashes the typed pointer in a `g_android_plat : *AndroidPlatform = null` global declared inside its `inline if OS == .android` import block, allocates + inits in `SxApp.onCreate` (BEFORE `setContentView` triggers `surfaceCreated`), and main() on the render thread reads it rather than re-allocating. Chess on Pixel 7 Pro now fills the screen end-to-end. Diagnostic was a one-shot `__android_log_print` inside `Gles3Gpu.set_vertex_constants` logging matrix elements + `self.pixel_w/h` — m0/m5 matched logical 414/831 (projection correct) while pixel_w/h were also 414/831 (viewport wrong), pinning the bug to upstream of `Gles3Gpu`. Instrumentation stripped after fix. 140 host + 9 cross tests green. ## Known issues - `signed char` C maps to sx `u8` in c_import.zig (current behavior; test snapshots it). - sx integer-literal parser rejects values ≥ 2^63 as "overflow" even when the receiving type is `u64`. Worked around in 0.1 by using `0x7FEE…` instead of `0xFEEDFACECAFEBEEF`.