# 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). Class-method declarations no longer need an explicit `static` keyword. The parser derives `is_static` from the first param's TYPE: if it's `*Self` the method is an instance method; anything else (including no params at all) is a class method. Surface examples now write `new :: (ctx: *JContext) -> *Self;` instead of `static new :: (...)`. The receiver param NAME doesn't matter — the type is the contract. Updated: `library/modules/platform/android.sx`, `examples/ffi-jni-class-03-static.sx`, `examples/ffi-jni-main-03-ctor.sx`, `examples/ffi-objc-dsl-05-static.sx`. Phase 3 step 3.2 in flight. Plan at `~/.claude/plans/lets-see-options-for-merry-dijkstra.md`. Three parts: (A) `#selector("...")` override, (B) golden mangling-table fixture, (C) uikit.sx migration to declarative `#objc_class` (5 clusters, foreign classes only — sx-defined classes wait for Phase 3.7). This commit lands A1 — the xfail half of the `#selector` cadence. `examples/ffi-objc-dsl-06-selector-override.sx` exercises the surface form (both static `NSObject.gimme()` with override "description" and an instance-method `NSDictionary.lookup` with override "objectForKey:"). The parser doesn't know the `#selector` token yet, so the snapshot captures the parser error and exit=1. Next commit (A2) wires lexer/parser/AST/lowering and flips the snapshot. Phase 3.2 A2 landed: `#selector("explicit:string")` override wired end-to-end. Lexer token `hash_selector`, AST field `selector_override: ?[]const u8` on `ForeignMethodDecl`, parser block mirroring `#jni_method_descriptor`, lowering in `deriveObjcSelector` returning `{ sel, keyword_count, is_override }`. Both `lowerObjcMethodCall` and `lowerObjcStaticCall` honor the override; arity-mismatch under the override path downgrades from `.err` to `.warn` (the runtime doesn't validate colon-vs-arg the way JNI's `GetMethodID` validates descriptors). Snapshot for `ffi-objc-dsl-06-selector-override.sx` flipped to working output. Phase 3.2 B landed: `examples/ffi-objc-dsl-07-mangling-table.sx` exercises 7 mangling shapes (niladic, arity 1-4, camelCase across pieces, override) in one fixture. Both `.txt` and `.ir` snapshots locked — a change to `deriveObjcSelector` produces one diff that surfaces every affected case at once via the `OBJC_METH_VAR_NAME_*` constants in the IR. Phase 3.2 C1 landed: Foundation utility cluster in uikit.sx migrated to declarative `#objc_class` bodies. Five classes declared near the top of the file (NSValue, NSNumber, NSDictionary, NSMutableDictionary, NSSet). Call sites rewritten from `#objc_call(T)(recv, "sel:", args)` to `recv.method(args)` / `Cls.method(args)`. Receivers cast from `*void` to the typed foreign-class pointer at the dispatch site. The `objc_getClass(...)` calls for these classes are gone — the class slot is now populated by emit_llvm's `__sx_objc_class_init` constructor (Phase 3.1). Phase 3.2 C2 landed: notifications + bundle cluster migrated. NSNotification (`userInfo`), NSBundle (`mainBundle`, `resourcePath`), NSNotificationCenter (`defaultCenter`, `addObserver_selector_name_object`) declared as `#foreign #objc_class` blocks. The 4-keyword `addObserver:selector:name:object:` selector derives cleanly from the underscore-separated sx name (`addObserver_selector_name_object`). Phase 3.2 C3 landed: RunLoop + display-timing cluster. NSRunLoop (`currentRunLoop`) and CADisplayLink (`displayLinkWithTarget_selector`, `addToRunLoop_forMode`, `targetTimestamp`, `duration`) declared as `#foreign #objc_class` blocks. The `link` parameter on the `sxTick:` callback is now cast to `*CADisplayLink` at function entry so subsequent method calls type-check. **Phase 3.2 C4/C5 BLOCKED on issue-0043.** Attempted C4 migration (UIKit chrome: UIScreen / UIWindow / UIViewController / UITextField / UIView) surfaced a real compiler bug: lazy-lowered function bodies don't resolve foreign-class method dispatch when invoked transitively from an `inline if OS == .ios` branch in another function. The specific failure is in `uikit_scene_will_connect_ios` whose body contains `UIWindow.alloc().initWithWindowScene(scene)` and `win.setRootViewController(...)` — both work in isolated probes but fail at compile time when the function is reached via lazy lowering from chess's iOS scene-connect hook. macOS target builds fine; only ios-sim trips it. C1/C2/C3 happened to land cleanly because the methods they migrate are reached eagerly (or are niladic so the dispatch path doesn't hit the failing branch). The C4 work is reverted to keep the tree green at C3. C4+C5 stay pending until issue-0043 is fixed in a separate session. Open work: - **issue-0043** — investigate + fix the lazy-lower foreign-class dispatch bug. See `issues/0043-lazy-lower-loses-foreign-class-method-dispatch.md` for the reproduction and investigation prompt. - **Phase 3 step 3.2 — C4..C5** — uikit.sx migration, blocked until 0043 lands. 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`.