Files
sx/current/CHECKPOINT-FFI.md
agra 35ef32ffdb ffi M5.A.next: checkpoint — pack feature step 1 done (1c.A → 1d.B)
Logs the four commits that closed out step 1 of the variadic
heterogeneous type packs feature:

- 1c.A: parser-rejection lock-in for `..$args` inside `Closure(...)`.
- 1c.B: parser + AST + types.zig `pack_start` representation.
- 1d.A: impl-matching concrete-only miss lock-in.
- 1d.B: pack-aware impl matching with $args + $R binding through
  `param_impl_pack_map` and `pack_bindings`.

Next step is plan step 2 — runtime `args[$i]` indexing + per-mono
mangling — opening the door to body-side pack reflection that
step 5 needs to retire the hand-rolled `Into(Block)` impls.

Also catches up the 1b entry which the prior session left
uncommitted in the working tree.
2026-05-27 12:58:57 +03:00

88 KiB
Raw Blame History

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

M5.A.next.1d.B — variadic heterogeneous type packs: pack-aware impl matching (commit 08feb60).

Pack-shaped impls (impl P(...) for Closure(..$args) -> $R) now match concrete closure sources at xx resolution time. Concrete impls retain priority — pack matching only fires when param_impl_map misses on the concrete key.

New plumbing in src/ir/lower.zig:

  • PackParamImplEntry carries the pack-shaped source TypeId plus the pack-var and ret-var names extracted from the impl AST's target_type_expr. registerParamImpl detects pack-shaped sources via pack_start != null on the resolved closure type and additionally registers in a new param_impl_pack_map keyed by "Proto\x00<arg_mangled>" (no source suffix).
  • tryUserConversion re-shapes the lookup so the pack path runs on miss. tryPackImplMatch walks the pack entries, verifies the source's fixed prefix matches the impl's prefix, binds the pack-var to the source's tail param TypeIds, binds the ret-var (when the impl's return is generic) to the source return, and monomorphises the convert method. Mangled name stays keyed on the concrete source so distinct call shapes monomorphise separately.
  • pack_bindings: ?StringHashMap([]const TypeId) is saved / restored around monomorphisation, mirroring type_bindings.
  • resolveClosureTypeWithBindings handles the closure_type_expr node during type resolution: when the closure carries a pack_name AND pack_bindings has a binding for it, the bound TypeIds are appended after the fixed prefix and the result is a concrete (non-pack) closure type — so the impl body's self: Closure(..$args) -> $R substitutes to the concrete source closure during monomorphisation.

examples/155-pack-impl-match.sx flips from the "no Into(Block) for cl_s32_bool__bool" lock-in diagnostic to "pack impl match ok": one user-declared impl Into(Block) for Closure(..$args) -> $R covers a Closure(s32, bool) -> bool source that stdlib has no hand-rolled impl for. The constructed Block isn't invoked (invoke=null) — the test exercises matching + monomorphisation, not the trampoline (step 5 of the plan).

Same-file duplicate pack impls diagnose at registration; cross-module pack-impl visibility and multi-pack-impl specificity are deferred (matching the concrete path's existing TODOs).

193/193 example tests + zig build test green. Step 1 of the pack-feature plan ("Parser + type rep + impl matching") is now done.

Next step — Step 2 of the plan: runtime indexing (args[$i]) lowers to positional access; per-mono mangling extends with a stable pack-shape hash. Builder fns receive $args (a comptime []Type) as a regular value parameter. Replaces a hand-rolled Into impl in stdlib once step 2 + step 3 (type-reflection intrinsics) land.


M5.A.next.1d.A — pack impl matching: lock in concrete-only miss (commit ce3c2fe).

Pinned today's matching behaviour ahead of 1d.B. A user-declared impl Into(Block) for Closure(..$args) -> $R registers under a pack-shaped source key in param_impl_map; the xx site mangles the concrete Closure(s32, bool) -> bool source and finds nothing → the existing focused diagnostic fires ("no Into(Block) for cl_s32_bool__bool impl — add a per-signature __block_invoke_<sig> trampoline + Into impl..."). The pack impl is reachable in the file but never considered.

examples/155-pack-impl-match.sx captures the rejection at line 43 column 21 (the xx cl : *Block site). 193/193 example tests

  • zig build test green.

M5.A.next.1c.B — pack type rep: Closure(..$args) parses + interns (commit 6582449).

parseTypeExpr's Closure(...) arm now accepts a trailing ..$name (sigil optional) as a variadic-pack marker. Pack must be terminal — ) is the only token accepted after the name. ClosureTypeExpr AST gains pack_name: ?[]const u8 carrying the identifier so later slices can name the binding.

FunctionInfo / ClosureInfo in src/ir/types.zig grow a pack_start: ?u32 = null field. Closure(..$args) -> R interns as params = [], pack_start = Some(0) — distinct from any concrete Closure(...) -> R shape thanks to updated hash/eql arms. New constructor pair closureTypePack / functionTypePack keeps the existing single-shape constructors unchanged.

type_bridge.resolveClosureType calls closureTypePack when pack_name != null. The pack starts after the fixed prefix, so Closure(Prefix, ..$args) resolves with params = [Prefix], pack_start = Some(1).

examples/154-pack-type-rep.sx flips from rejecting-with-error to positive parse smoke. 192/192 example tests + zig build test green.


M5.A.next.1c.A — pack type rep: lock in parser rejection (commit bb6eca6).

Locked in today's parseTypeExpr Closure-arm rejection of ..$args. examples/154-pack-type-rep.sx uses ..$args inside a Closure(...) type expression — the pack-shape spelling used by impl headers like impl Into(Block) for Closure(..$args) -> $R. Today's parser recognized ..$args only at parameter-list sites (1b); the Closure type arm called parseTypeExpr per position and hit "expected type name" at line 18 column 26.


M5.A.next.1b — variadic heterogeneous type packs: parser accepts ..$args (commit a51fe26).

parseParams() in src/parser.zig:1558 accepts a leading .. before the optional $ sigil and the parameter name. The old args: ..T form (variadic marker after the colon) still works — both paths set the same is_variadic flag. A pack declaration ..$args parses as:

  • is_variadic = true (leading ..)
  • is_comptime = true (the $ sigil)
  • type_expr = inferred_type (no : annotation)

examples/150-pack-parse.sx flipped from rejecting-with-error to positive parse smoke. The no-colon branch of parseParams propagates is_variadic and is_comptime onto the Param struct, so later slices can read both flags from the parsed AST. 191/191 example tests + zig build test green.


M5.A.next.1a — variadic heterogeneous type packs: parse lockin (ad82847).

First slice of the ..$args (variadic heterogeneous type pack) feature per the plan saved at ~/.claude/plans/lets-see-options-for-merry-dijkstra.md (see the "Variadic heterogeneous type packs" section). Locks in the current parser-rejection behavior so the next commit's parser extension shows up as a behavior shift.

New: examples/150-pack-parse.sx declares foo :: (..$args) -> s64. Today's parser hits .. where it expects a parameter name (after parsing the leading dollar sigil that doesn't appear) and emits "expected parameter name" at column 9 of line 15. Expected output captures this rejection.

Per FFI cadence rule, this is a "test that fails today, passes after the next commit's parser change" pair. The next commit extends parseParams() (src/parser.zig:1558) to accept .. at the start of a parameter — currently the parser only handles .. inside the type position (after the colon).

191 example tests + zig build test green.

The pack feature itself lives in the FFI stream because its primary motivation is replacing the hand-rolled per-signature Into(Block) impls in library/modules/std/objc_block.sx with one generic impl Into(Block) for Closure(..$args) -> $R. Payoff extends beyond blocks — print/format get compile-time arity and type-mismatch errors instead of ..Any runtime tag dispatch.


M1.2 A.0 — objc_defined_class_cache + scan-pass registration (61a2593).

Added an insertion-ordered cache on Module for sx-defined Obj-C classes (every #objc_class("Cls") { ... } declaration WITHOUT #foreign). registerForeignClassDecl appends the entry alongside its existing foreign_class_map insert.

pub const ObjcDefinedClassEntry = struct {
    name: []const u8,
    decl: *const ast.ForeignClassDecl,
};

Pointer back to the AST lets later A.* passes re-walk members without duplicating data. Insertion order matters because class-pair init constructors (A.4) must register parents before children — objc_allocateClassPair(super, ...) resolves super by lookup. Infrastructure only; populated but not yet read.

170 example tests + zig build test green.


M1.1 first pass — id / Class / SEL / BOOL aliases (d9dbdad).

Added stand-ins for the opaque Obj-C runtime types to library/modules/std/objc.sx: id, Class, SEL resolve to *void; BOOL to s8. All zero-cost at the LLVM layer; the header's old caveat about lacking aliases is gone. 141-objc-type-aliases.sx exercises them against the real macOS Obj-C runtime via isKindOfClass.

Deferred to M1.1.b: Class(T) parameterization with #extends-aware covariance + instancetype per-decl substitution. Both need compiler-level type-check work beyond stdlib aliases. The current sx type system doesn't enforce nominal identity on parametric struct instantiations (verified probe: Class(NSString) flows into Class(CALayer) parameter without error), so a stdlib-only Class(T) would give syntax with no safety. Punted to a focused later slice.


M1.0 — Expression-bodied function declarations (3 commits: 6c95b2a, 4a048d3, 86c1127).

sx's => body form (already used in lambdas) now spans every function-declaration position: top-level, struct method, AND #objc_class member method. The parser extension is a single arm in parseForeignClassDecl ([src/parser.zig:1262]) that mirrors the existing parseFnDecl arrow handling.

Three commits, FFI cadence:

  • 6c95b2a ffi M1.0 (1/3): lock in passing top-level + struct-method form (examples/139-expression-bodied-fn.sx).
  • 4a048d3 ffi M1.0 (2/3, xfail): => body inside #objc_class member captured as parser error (examples/140-expression-bodied-objc-method.sx).
  • 86c1127 ffi M1.0 (3/3): parser extension, 140 flips green.

169 examples pass (+2 from M1.0). zig build test green.

This is the first milestone of the 6-month Obj-C FFI roadmap saved at ~/.claude/plans/lets-see-options-for-merry-dijkstra.md. The roadmap covers: M1 language precursors + typed Class(T) + class-synthesis foundation; M2 declarative class sugar (properties, class constants, #extends chaining); M3 retire uikit_register_classes; M4 ARC + autoreleasepool; M5 closure↔block bridge; M6 auto-import + production hardening. Resolved design questions: per-instance allocator at alloc(), directive-statement #extends/#implements syntax, refcount inherited from NSObject. Four design questions still open (see roadmap).


Prior landing — 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_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); declareFunction (src/ir/lower.zig:671) 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) 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) 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) 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.01.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/<name>.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(<T>) 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

  • 185/185 example tests pass; zig build test green.
  • Phase 3.0/3.1/3.2 + M1.0M1.3 + M2.1M2.3 + M3 + M4.0 + M4.A all landed.
  • Chess on macOS / iOS-sim / Android all build and run.

M4.0 — context.allocator threading (4 commits this session):

  • __sx_allocator: Allocator prepended at field index 0 of every sx-defined class's state struct (src/ir/lower.zig:objcDefinedStateStructType).
  • Sx-side Cls.alloc() intercepted in lowerObjcStaticCall for sx-defined classes — emits the inline alloc-and-init sequence using the caller's current_ctx_ref. push Context.{ allocator = arena } now backs the next SxFoo.alloc().
  • Runtime-side +alloc IMP is now a shim that reads __sx_default_context.allocator and forwards to the same shared helper.
  • Shared emitObjcDefinedAllocAndInit(fcd, cls_ref, ctx_addr) does the work: class_createInstance → ctx.allocator.alloc(STATE_SIZE) via the inline-protocol fn-ptr → memset 0 → store allocator at state[0] → object_setIvar(__sx_state).
  • -dealloc IMP loads state->__sx_allocator and dispatches allocator.dealloc(state) instead of the old raw free(state).
  • TrackingAllocator now sees sx-defined class alloc/dealloc pairs.

M4.A — stdlib NSObject + autoreleasepool (1 commit):

  • NSObject :: #foreign #objc_class("NSObject") declared in std/objc.sx with the full inherited surface: retain/release/ autorelease/new/alloc/init/class/description/hash/isEqual_/ isKindOfClass_/respondsToSelector_.
  • All previously-unrooted foreign classes in uikit.sx now #extends NSObject; (NSValue, NSNumber, NSDictionary, etc.) so M2.3's extends-chain dispatch finds retain/release on any UIKit type.
  • autoreleasepool(body: Closure()) stdlib helper wraps the push/defer-pop pair.
  • Canonical idiom enabled: view := UIView.alloc().init(); defer view.release();.
  • Smoke test examples/ffi-objc-arc-01-autoreleasepool.sx exercises the retain/release + autoreleasepool round-trip.

M4.B — property ARC ops (4 commits, done):

  • objcPropertyKind(field) + ensureArcRuntimeDecls helpers. The kind helper validates #property(...) modifiers and emits loud diagnostics for: unknown modifier names, conflicting modifiers, weak/copy on non-object fields, #property(strong) on *void.
  • Setter emits ARC ops per kind: strong → retain new + release old; weak → objc_storeWeak; copy → [val copy] + release old; assign → bare store.
  • Getter weak path → objc_loadWeakRetained + objc_autorelease for race-safe reads. Strong/copy/assign keep the bare load.
  • Dealloc walks #property ivars BEFORE freeing the state struct: release strong/copy ivars, destroyWeak weak ivars. Order: property cleanup → state free → [super dealloc].

Smoke tests ffi-objc-arc-02-strong-property (TrackingAllocator midpoint + balance) and ffi-objc-arc-03-weak-property (auto-nil after target dealloc) both pass.

189/189 example tests pass; chess on iOS-sim green throughout M4.

Previous-session wins still in this checkpoint:

  • M4.0 / M4.A built on top of these earlier commits this session:
  • library/modules/platform/uikit.sx follow-up cleanup just shipped: every plat: *UIKitPlatform helper and every (self: *void, _cmd: *void, ...) trampoline is now a method on UIKitPlatform. Method bodies in SxAppDelegate / SxSceneDelegate / SxGLView / SxMetalView call g_uikit_plat.x() for the shared paths and inline the trivial bridges (no more xx self, xx 0 casts at IMP-call sites). layerClass uses the declarative () => CAEAGLLayer.class() / CAMetalLayer.class() form on top of new foreign-class declarations for both layer types.
  • issue-0044 FIXED. The root cause was a target_type leak in resolveCallParamTypes for UFCS calls on foreign-class (#objc_class / #foreign #objc_class) receivers. With no param-types resolved for the receiver's method, self.target_type retained the enclosing fn's return type — and a BOOL-returning method's xx ptr inside an Obj-C call site silently truncated the pointer to i8. Fix at src/ir/lower.zig:8617-8639 walks foreign_class_map + findForeignMethodInChain for the method's declared param types. Regression test examples/issue-0044.sx. 184/184 example tests green; chess on iOS-sim green.
  • Active forward plan: 6-month Obj-C FFI roadmap at ~/.claude/plans/lets-see-options-for-merry-dijkstra.md.

Next step (M1.2 A.1 — type-encoding derivation table)

The synthesized +alloc (A.5), -dealloc (A.6), and every instance-method IMP (A.2) need to call class_addMethod(cls, sel, imp, types) with a type-encoding string in Apple's runtime DSL:

  • v = void, i = s32, q = s64, f = f32, d = f64, B = bool,
  • c = s8/BOOL, C = u8, s = s16, S = u16, l/L = long, Q = u64, * = [*]u8,
  • @ = id (object), # = Class, : = SEL, ^v = *void.
  • Struct: {Name=field1field2...}.

A.1 = objcTypeEncodingFromSignature helper in src/ir/lower.zig. Inputs: receiver-as-@, _cmd selector slot :, then return type + arg types from the IR signature. Lookup table over TypeId. No emission yet — A.1 is a pure helper that A.2-A.6 will call.

Bounded slice: probably 100-200 lines of Zig, one-pass switch over TypeId. No cadence-rule test needed (helper has no observable output on its own; tested via integration with A.2+).

Phase 1B complete (1.61.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.1113 #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_bundleNSBundle.mainBundle.resourcePath chain (2× *void returns through one msg_o cast) done (3518d0e)
1.27 uikit_read_screen_scaleUIScreen.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_contextalloc / 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_frameuserInfo / 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.31.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_<key> and @SX_JNI_MID_<key> globals per unique pair; per-call lowering does null-check + lazy populate via GetObjectClassNewGlobalRef (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.191.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.
1.23 #jni_static_call — skip GetObjectClass (target IS jclass), use GetStaticMethodID (113) + CallStatic<Type>Method 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.151.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 + CallStatic<Type>Method 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. <android/native_activity.h> and <jni.h> 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.251.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.bodymethods: []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: JniClassDeclForeignClassDecl carrying a runtime: ForeignRuntime enum discriminator; JniMethodDecl/JniFieldDecl/JniClassMember renamed to Foreign*; AST variant jni_class_declforeign_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<path>; 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[<elem>. 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: [<elem> (recursive).
  • *SelfL<enclosing_path>;.
  • *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_<m>(...); } 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 <stage>/java/<pkg>/<Class>.java, invokes javac --release 11 -classpath <android_jar> to <stage>/classes/, invokes d8 --release --lib <android_jar> --output <stage> to produce <stage>/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/javacwhich 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 <activity android:name="..."> to the user's class + android:hasCode="true".
  • jm.4 — lower emits a synthetic JNI_OnLoad that calls RegisterNatives to bind the sx_<method> 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.1R.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. 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_<Cls>, 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.

issue-0043 closed. The "lazy-lower" framing in the issue file turned out to be a red herring: the actual root cause was that inferExprType for a chained call Cls.static().instance(...) never looked the inner call's foreign-class declaration up, so the outer dispatch saw a .s64 receiver, the foreign_class_map.get(...) lookup missed, and lowering emitted error: unresolved 'method'. The macOS target appeared to work because inline if OS == .ios { ... } strips the gated body before lowering — eliding every call that would have exercised the broken path.

Fix in src/ir/lower.zig:

  1. inferExprType for .call with .field_access callee now checks foreign_class_map for both shapes — Cls.static_method(args) (object identifier matches a foreign-class alias, look up static members) and inst.instance_method(args) (receiver is a pointer to a foreign-class struct, look up non-static members).
  2. New helpers resolveForeignMethodReturnType / resolveForeignClassMemberType substitute *Self / Self to the foreign-class struct so a *Self return doesn't synthesize a phantom Self-named struct that future dispatches can't resolve.
  3. The Obj-C lowering paths (lowerObjcMethodCall, lowerObjcStaticCall) route through the same helper for ret_ty so the IR Ref's type matches what inferExprType reports.

examples/138-foreign-class-chained-dispatch.sx locks in the regression via two shapes against NSObject's +alloc / -init chain: *NSObject return then *Self return, and *Self then *Self. Runs on the host (macOS) for live exercise; non-macOS hosts fall through to a stub matching the expected output.

Phase 3.2 C4 landed: UIKit chrome cluster migrated. Six classes declared (UIScreen, UIView, UIWindow, UIViewController, UITextField — plus the existing C1/C2/C3 classes already in place). Three objc_getClass(...) calls (UIWindow, UIViewController, UITextField) are gone — the class slots come from the declarative bindings via __sx_objc_class_init. C4 is the cluster that triggered issue-0043; with the fix in, the chained dispatch resolves correctly under lazy lowering.

Phase 3.2 C5 landed: view tree + GL drawables cluster migrated. CALayer (setOpaque), CAEAGLLayer (setDrawableProperties), and EAGLContext (alloc, initWithAPI, setCurrentContext, renderbufferStorage_fromDrawable, presentRenderbuffer) declared. UIView gained setContentScaleFactor and layer now returns *CALayer (was opaque *void). Migration sites: uikit_create_gl_context uses EAGLContext.alloc().initWithAPI(...) then EAGLContext.setCurrentContext(ctx); uikit_setup_renderbuffer uses gl_ctx.renderbufferStorage_fromDrawable(...); uikit_present_renderbuffer uses gl_ctx.presentRenderbuffer(...); the scene-connect bring-up uses gl_layer.setOpaque(1), eagl_layer.setDrawableProperties(...), and gl_view.setContentScaleFactor(scale). One more objc_getClass (EAGLContext) gone. 167/167 + chess clean on macOS / iOS sim / Android.

Phase 3.2 complete. Surface summary:

  • #selector("explicit:") override (parts A1+A2).
  • Locked-in golden mangling-table test (part B).
  • Five uikit.sx clusters migrated to declarative #objc_class (parts C1..C5) — 8 foreign Cocoa classes declared, 30+ #objc_call call sites rewritten to recv.method(args) / Cls.method(args) form. 6 redundant objc_getClass(...) lookups retired. Sx-defined classes (SxAppDelegate, SxSceneDelegate, SxGLView, SxMetalView) and a handful of foreign sites that exercise less common paths (e.g. objc_call(void)(delegate, "setWindow:", ...) from UIWindowSceneDelegate protocol) stay on the explicit #objc_call form pending Phase 3.7's class-synthesis work.

Open work:

  • Phase 3 step 3.3property 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.43.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.7impl 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.03.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):

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.00.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.40.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.01.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/<name>.ir snapshots catch lowering changes invisible in runtime output.

  • 2026-05-19: Phase 1.61.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.281.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_<key> and @SX_JNI_MID_<key> 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) drops the variadic param from the IR signature and sets Function.is_variadic; emitFunctionDecl (src/ir/emit_llvm.zig:682) 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 (lengthlength); 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_<mangled> 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 Call<T>Method 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 #imported 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.

  • 2026-05-25: issue-0043 closed — chained Cls.static().instance(...) foreign-class dispatch. inferExprType for .call with .field_access callee now consults foreign_class_map for both static (object is the alias) and instance (receiver type is *ForeignClass) shapes. New resolveForeignMethodReturnType / resolveForeignClassMemberType / foreignClassStructType helpers substitute *Self / Self to the foreign class's own struct so the chained receiver type doesn't collapse to a phantom Self-named struct. lowerObjcMethodCall / lowerObjcStaticCall route through the same helper so the IR Ref's recorded ret_ty matches what inferExprType reports. Pre-fix: UIWindow.alloc().initWithWindowScene(scene) (and any other chained shape) collapsed the inner ret to .s64, the next dispatch's foreign_class_map.get(...) missed, and lowering emitted error: unresolved 'initWithWindowScene'. The "lazy-lower" wording in the issue file is a red herring — the bug fires on direct calls too; macOS chess hides it only because inline if OS == .ios { ... } strips the gated bodies that exercise the chain. Locked in by examples/138-foreign-class-chained-dispatch.sx (NSObject +alloc / -init chain in both *Cls and *Self return-type shapes). 167 host + 7 cross tests green. Phase 3.2 C4/C5 is unblocked.

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.