First slice of the `..$args` (variadic heterogeneous type pack)
feature. Locks in the current parser-rejection behavior so the
next commit's parser extension shows up as a behavior shift.
`examples/150-pack-parse.sx` declares `foo :: (..$args) -> s64`.
Today's parser hits `..` where it expects a parameter name
(parseParams in src/parser.zig:1558 only handles `..` inside the
type position after a colon) and emits "expected parameter name".
Expected output captures this rejection.
Per FFI cadence rule, this is the "test fails today, passes after
next commit's parser change" pair.
Pack feature plan saved at
~/.claude/plans/lets-see-options-for-merry-dijkstra.md ("Variadic
heterogeneous type packs" section). Motivates replacing the
hand-rolled per-signature `Into(Block)` impls with one generic
`impl Into(Block) for Closure(..$args) -> $R`; also unlocks
compile-time arity/type errors for `print`/`format`.
191/191 example tests + `zig build test` green.
83 KiB
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.1a — variadic heterogeneous type packs: parse lockin (this commit).
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:
6c95b2affi M1.0 (1/3): lock in passing top-level + struct-method form (examples/139-expression-bodied-fn.sx).4a048d3ffi M1.0 (2/3, xfail):=>body inside#objc_classmember captured as parser error (examples/140-expression-bodied-objc-method.sx).86c1127ffi 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):
- 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) passesis_var_arg=1toLLVMFunctionTypewhen the flag is set; the per-call-siteLLVMBuildCall2already passes all args through, so extras land in the variadic slot via the linker-fixed C ABI. - 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. - 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 → s32via sext/zext,f32 → f64via fpext. Wired into the twolowerCallpaths right aftercoerceCallArgs.
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/<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.coerceArginemit_llvm.ziglearned 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.
emitFunctionDeclnow collapses the ret type to void, prepends aptrparam at index 0 with thesret(<T>)type attribute, and the.calllowering 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 byprocessCImportbecauseconst 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/.hbaseline helpers fromvendors/ffi_*/...intoexamples/ffi-NN-*.{c,h}next to their.sx. Thevendors/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 in0bb7b8c(coerceToType+bitcastIR opcode now bridge ptr↔int explicitly; previously axx ptrcast targetingintsilently no-op'd, leaving the return asret i64 undef). Promoted toexamples/102-foreign-global-from-helper.sx.
Current state
- 185/185 example tests pass;
zig build testgreen. - Phase 3.0/3.1/3.2 + M1.0–M1.3 + M2.1–M2.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: Allocatorprepended at field index 0 of every sx-defined class's state struct (src/ir/lower.zig:objcDefinedStateStructType).- Sx-side
Cls.alloc()intercepted inlowerObjcStaticCallfor sx-defined classes — emits the inline alloc-and-init sequence using the caller'scurrent_ctx_ref.push Context.{ allocator = arena }now backs the nextSxFoo.alloc(). - Runtime-side
+allocIMP is now a shim that reads__sx_default_context.allocatorand 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). -deallocIMP loadsstate->__sx_allocatorand dispatchesallocator.dealloc(state)instead of the old rawfree(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)+ensureArcRuntimeDeclshelpers. 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_autoreleasefor race-safe reads. Strong/copy/assign keep the bare load. - Dealloc walks
#propertyivars 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.sxfollow-up cleanup just shipped: everyplat: *UIKitPlatformhelper and every(self: *void, _cmd: *void, ...)trampoline is now a method onUIKitPlatform. Method bodies in SxAppDelegate / SxSceneDelegate / SxGLView / SxMetalView callg_uikit_plat.x()for the shared paths and inline the trivial bridges (no morexx self, xx 0casts at IMP-call sites).layerClassuses 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_typeleak inresolveCallParamTypesfor UFCS calls on foreign-class (#objc_class/#foreign #objc_class) receivers. With no param-types resolved for the receiver's method,self.target_typeretained the enclosing fn's return type — and aBOOL-returning method'sxx ptrinside an Obj-C call site silently truncated the pointer to i8. Fix at src/ir/lower.zig:8617-8639 walksforeign_class_map+findForeignMethodInChainfor 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.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 skippedFfiIntrinsicCallnodes. Fixed in df2ccf7; promoted toexamples/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_<key> and @SX_JNI_MID_<key> 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. |
| 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.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 + 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.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_insetsbody) - 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<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). *Self→L<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/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
<activity android:name="...">to the user's class +android:hasCode="true". - jm.4 — lower emits a synthetic
JNI_OnLoadthat callsRegisterNativesto bind thesx_<method>symbols to sx-side fns. Bodied methods inside#jni_maindecls are no-ops in lower today; this slice turns them into real native functions. - jm.5 — ship
modules/runtime/jni/native_activity.sxso users override individual lifecycle methods on a stdlib-provided Activity rather than declaring their own from scratch. - jm.exclusive — sema enforces exactly one
#jni_maindecl 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:
- sx's IR module can declare
thread_localglobals, and they work in AOT (platform linker handles TLS via dyld / bionic / etc). - But LLVM ORC JIT's default
LLVMOrcCreateLLJIT(&jit, null)ships no "platform" plugin to allocate TLS slots for objects added viaLLVMOrcLLJITAddObjectFile. Athread_localglobal in the user IR module → crash at module load. - 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.
- 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. 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:
inferExprTypefor.callwith.field_accesscallee now checksforeign_class_mapfor both shapes —Cls.static_method(args)(object identifier matches a foreign-class alias, look up static members) andinst.instance_method(args)(receiver is a pointer to a foreign-class struct, look up non-static members).- New helpers
resolveForeignMethodReturnType/resolveForeignClassMemberTypesubstitute*Self/Selfto the foreign-class struct so a*Selfreturn doesn't synthesize a phantomSelf-named struct that future dispatches can't resolve. - The Obj-C lowering paths (
lowerObjcMethodCall,lowerObjcStaticCall) route through the same helper forret_tyso the IR Ref's type matches whatinferExprTypereports.
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_callcall sites rewritten torecv.method(args)/Cls.method(args)form. 6 redundantobjc_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_callform pending Phase 3.7's class-synthesis work.
Open work:
- Phase 3 step 3.3 —
property name: Typesynthesizesinst.name→[inst name]getter andinst.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 SxTypesynthesizes a runtime Obj-C class viaobjc_allocateClassPair/class_addMethod/class_addProtocol/objc_registerClassPair. Replaces the hand-writtenuikit_register_classesbody inlibrary/modules/platform/uikit.sx. - Phase 3 step 3.8 — uikit.sx migration: retire every
objc_getClasslookup + hand-written class registration in favor of the#objc_class/impl Protocol for ...surface that 3.0–3.7 ship.
After Phase 3:
#jni_mainslice jm.5 — stdlib base classlibrary/modules/runtime/jni/native_activity.sxso 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_classdecls 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.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_globalfrom 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/<name>.irsnapshots 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 toexamples/102-foreign-global-from-helper.sx. issue-0038 (closure free-var analysis skipsFfiIntrinsicCall) still open. -
2026-05-19: Phase 1D cluster 1.25 done —
uikit_refresh_safe_insetsmigrated to#objc_call(UIEdgeInsets)(plat.gl_view, "safeAreaInsets"); deadsel_safe_insetsdecl dropped fromuikit_scene_will_connect_ios. Net -3 lines. Chess iOS-sim + Android still compile clean. Committed asbcbf2ac. iOS-sim chess: board renders with correct status-bar clearance. -
2026-05-19: Phase 1D cluster 1.26 done —
uikit_chdir_to_bundlemigrated to two#objc_call(*void)calls (mainBundleclass methodresourcePathinstance method). Net -3 lines. iOS-sim chess: app loads with all piece assets rendered (proveschdirto the bundle resource path still succeeds).
-
2026-05-19: Phase 1D cluster 1.27 done —
uikit_read_screen_scalevia#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 confirmsdpi_scaleis 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.
- the screen-scale dup from 1.27);
-
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) — wroteexamples/ffi-objc-call-11-bool-return.sxto lock in#objc_call(bool)against twoclass_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 staleEAGLContext := 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/durationper-frame reads, layerbounds, touchlocationInView:(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_msgSendtyped casts remain. uikit.sx 839 lines (937 → -98). -
2026-05-19: Phase 1C started. Step 1.15 done (
134c197xfail +9afcaa5fix). New.jni_msg_sendIR 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 rawptrvia newextractSlicePtrhelper. Static dispatch + non-void returns drop toLLVMGetUndef(next steps wire them). Android cross-compile passes forexamples/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). Addedexamples/ffi-jni-call-03-methodid-sharing.sxwith two#jni_callsites against literal("noop", "()V"); IR snapshot locks in today's two-GetMethodID-call shape. Runtime is a no-op —unused_jnireachable through a runtime-readableg_should_callglobal 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_keycarries 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 attests/expected/ffi-jni-call-03-methodid-sharing.irupdated. -
2026-05-19: issue-0038 closed (
35359b8xfail +df2ccf7fix).collectCapturesinsrc/ir/lower.zignow has the missing.ffi_intrinsic_callarm — closure free-variable analysis walksreturn_type+ everyargs[i].examples/issue-0038.sxrenamed toexamples/103-ffi-closure-capture.sx. Workaround inexamples/ffi-objc-call-09-in-construct.sx(module-globalg_hasher_recv) removed; closure now capturesrecvfrom its enclosing fn arg list normally. -
2026-05-19: 1.32 backfill (
ac78490) — wroteexamples/ffi-objc-call-12-rect-u64-returns.sx. Locks in#objc_call(CGRect)(4×f64 HFA) and#objc_call(u64)against twoclass_addMethod-registered IMPs. 111/111 host tests pass. No outstanding FFI verification gaps. -
2026-05-20:
#jni_mainslice jm.2 done — AOT pipeline integration.Compilation.lowering_jni_main_declspopulated bylowerToIR,createApkextended withjavac+d8+ classes.dex zip step. Smoke atexamples/ffi-jni-main-01-emit.sx(added tocross_compile.shas android tuple). 131 host + 4 cross tests green. Manual APK inspection:dexdump -l plainshowsLco/swipelab/sxjnimain/SxApp;extending NativeActivity inclasses.dex. EGL demo APK still bundles without a .dex (no regression on the no-#jni_mainpath). -
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_mainActivity (or any other decl, including#import) wrapped in the gate is visible on the matching target and invisible everywhere else. NewflattenComptimeConditionalspass inimports.zigruns at the head ofresolveImports, walkingOS/ARCH/POINTER_SIZEagainst the current target's enum variant; nested forms (inline if X { inline if Y { ... } }) are recursed into.parseStmtlearned to accept#import/#frameworkinsideinline ifbodies (the parser doesn't know the enclosing context at parse time — the flatten pass is the only place that surfaces them). issue-0042 promoted toexamples/107-top-level-inline-if-os-gate.sx; companionexamples/108-top-level-inline-if-imports.sx+ two helpers exercise the per-arm#importpath (host arms pullgated_label => 1from one helper, else arm pullsgated_label => 2from the other). 138 host + 8 cross tests green. -
2026-05-20: issue-0044 fixed —
#jni_mainmethod bodies couldn't call deferred-type-fns (e.g.format(...)→any_to_string).Lowering.lowerRootranlowerDeferredTypeFns(Pass 3) beforesynthesizeJniMainStubs(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 (corruptedPlatform.begin_frame() -> FrameContextat 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.sxpins the cross-compile regression (compile-only Android), andexamples/111-protocol-vtable-sret-mixed-struct.sxlocks 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-
undeffallback sweep insrc/ir/emit_llvm.zig. ~25 sites where IR opcodes / map lookups / type-kind guards used to silentlyLLVMGetUndef(...)on a "shouldn't happen" path now emit a proper compiler error via the newly-wireddiagnostics: ?*errors.DiagnosticListfield (Compilation.generateCode sets it beforeemit()). Covers JNI msg_send return-type switches (instance / static / nonvirtual), map lookups (global_get/_addr,func_ref,callcallee,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.outbuiltinelsearm), and the fourgetRefIRType(arg_ref) orelse .voidper-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 —
#foreignC-variadic tail. Trailingargs: ..Ton 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 setsFunction.is_variadic;emitFunctionDecl(src/ir/emit_llvm.zig:682) passesis_var_arg=1toLLVMFunctionTypeaccordingly. NewpromoteCVariadicArgsapplies C default argument promotion (bool/s8/s16/u8/u16 → s32,f32 → f64) to extras past the fixed param count.packVariadicCallArgsearly-outs for foreign+variadic so the slice-packing path is bypassed entirely. New testexamples/ffi-foreign-cvariadic.sx+.cexercise s64 / f64 / s32 returns through Cva_argover s32 / f64 /*u8element 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-driven08-typesstruct field (test readsu8 = ---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_protocolreceivers now lowers cleanly.lowerForeignMethodCallbranches onfcd.runtime: Obj-C runtimes route through the newlowerObjcMethodDispatchhelper, JNI runtimes keep the existing path, Swift stays deferred. Default selector mangling lives inderiveObjcSelector: 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 withObj-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 existinginternObjcSelector+objc_msg_sendIR 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_sendreturn-type switch grew rows for.s8/.s16/.u16(jbyte / jshort / jchar). NewLLVMEmitter.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.validateJniTyperelaxed to accept the newly-supported set:.signed{8,16,32,64} /.unsigned{8,16} / bool / f32 / f64 / pointer / void (for returns only). Also reorderedlowerForeignMethodCallso signature validation runs BEFOREderiveMethod— otherwise the descriptor derivation'sUnknownPrimitiveerror fires first with the call-site span, hiding the more useful "unsupported return/parameter type at this token" diagnostic. New cross-compile testexamples/114-jni-promoted-narrow-types.sxexercises a#jni_classreturnings8 / s16 / u16and a varargs method taking(s8, s16, u16, f32); IR shows the expectedsext i8 → i32,sext i16 → i32,zext i16 → i32, anddouble 1.5e+00(FPExt folded for the constant) at the call site. Tests 112 / 113 migrated to useu32(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.
lowerForeignMethodCallnow iteratesmethod.params(skipping the implicit*Selffor instance methods) and rejects unsupported parameter types at the type token's span;lowerJniCallvalidates each method arg's TypeId post-lowering against the arg expression's span. Same supported set as returns (bool / s32 / s64 / f32 / f64 / pointer) minusvoidfor params. Refactor splitsvalidateJniReturnType/validateJniParamTypeover a sharedvalidateJniTypecore 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 testexamples/113-jni-unsupported-param-type.sxlocks 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+lowerForeignMethodCallinsrc/ir/lower.zig) so the diagnostic carries the return-type slot's source span. NewLowering.validateJniReturnTypehelper mirrors the supported set in emit_llvm's.jni_msg_sendswitch (void / bool / s32 / s64 / f32 / f64 / pointer types); a*Foo.bad()call wherebad()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 testexamples/112-jni-unsupported-return-type.sxlocks 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 JNICall<T>Methodreturn-type switch (instance / static / nonvirtual) was missing.f32, so#jni_classmethods likeMotionEvent.getX/getY()lowered toLLVMGetUndef(...)and never actually invoked the Java method — garbage f32 values flowed through chess's touch pipeline, hitScrollView.handle_event'sframe.contains(pos)as NaN, and were silently rejected. Added.f32 => Jni.CallFloatMethodplus the static / nonvirtual parallels (the matchingJni.Call[Static| Nonvirtual]FloatMethodconstants were already defined at emit_llvm.zig:50, 62, 74; only the switch rows were missing). Same edit replaced the bareelse => { undef; return; }arms in all three switches withstd.debug.panic("JNI {variant} call: unsupported return type {s}", .{@tagName(ret_ty_id)})so any future missing row crashes the compiler loudly instead of shippingundefto 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.sxto zero module-level globals. Root cause: android.sx exportedg_viewport_w : s32 = 0andg_viewport_h : s32 = 0at module scope; chess'smain.sxdeclared its owng_viewport_w : f32 = 800.0at 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_wthen fedglViewport(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. Allsx_android_*helpers now takeplat: *AndroidPlatformas their first arg; render thread entry readsplatviapthread_create'sarg. Consumer (chess) stashes the typed pointer in ag_android_plat : *AndroidPlatform = nullglobal declared inside itsinline if OS == .androidimport block, allocates + inits inSxApp.onCreate(BEFOREsetContentViewtriggerssurfaceCreated), 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_printinsideGles3Gpu.set_vertex_constantslogging 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 ofGles3Gpu. Instrumentation stripped after fix. 140 host + 9 cross tests green. -
2026-05-25: issue-0043 closed — chained
Cls.static().instance(...)foreign-class dispatch.inferExprTypefor.callwith.field_accesscallee now consultsforeign_class_mapfor both static (object is the alias) and instance (receiver type is*ForeignClass) shapes. NewresolveForeignMethodReturnType/resolveForeignClassMemberType/foreignClassStructTypehelpers substitute*Self/Selfto the foreign class's own struct so the chained receiver type doesn't collapse to a phantomSelf-named struct.lowerObjcMethodCall/lowerObjcStaticCallroute through the same helper so the IR Ref's recorded ret_ty matches whatinferExprTypereports. Pre-fix:UIWindow.alloc().initWithWindowScene(scene)(and any other chained shape) collapsed the inner ret to.s64, the next dispatch'sforeign_class_map.get(...)missed, and lowering emittederror: 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 becauseinline if OS == .ios { ... }strips the gated bodies that exercise the chain. Locked in byexamples/138-foreign-class-chained-dispatch.sx(NSObject+alloc/-initchain in both*Clsand*Selfreturn-type shapes). 167 host + 7 cross tests green. Phase 3.2 C4/C5 is unblocked.
Known issues
signed charC maps to sxu8in 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 using0x7FEE…instead of0xFEEDFACECAFEBEEF.