Files
sx/current/CHECKPOINT-FFI.md

121 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.5 — generic Into(Block) for Closure(..$args) -> $R (commits 3bd6f262eaf932, plus 3 unblocking compiler-bug fixes — issues 0048 / 0049 / 0050).

The visible end-user payoff for the whole variadic-heterogeneous- type-packs feature. One stdlib impl replaces every per-signature hand-rolled Into(Block) pair; the compiler monomorphises the impl body per call shape and emits a dedicated __invoke callconv(.c) trampoline + Block literal via a single #insert build_block_convert($args, $R).

Slice Commit What
5.0 probe (no commit) Confirmed nested () -> Ret callconv(.c) { body } parses + @inner address-of binds + indirect call works. The trampoline emission pattern is unblocked at the language-surface level.
issue-0048 xfail 8fcf352 Bare $args slice loses .len across a function-call boundary — pin via examples/173-pack-bare-args-cross-call.sx.
issue-0048 fix 0ede097 lazyLowerFunction saves/nulls pack_arg_nodes / pack_param_count / pack_arg_types / inline_return_target before lowering the callee body. Without it, a lazily lowered regular fn called from inside a pack-fn mono inherited the outer pack maps and lowerFieldAccess's <pack_name>.len intercept folded the callee's same-named param to the outer mono's arity.
issue-0049 xfail 64dcbca New-form variadic ..parts: []string defined in stdlib + called from another module crashes LLVM emit (LLVMBuildExtractValue inside emitStrCmp). Pinned via the path_join migration.
issue-0049 fix b5301c4 resolveParamType + packVariadicCallArgs now detect when a variadic param's declared type is already a slice (..name: []T) and use it as the element-shape container rather than wrapping []T to [][]T.
variadic migration 5b3d864 Stdlib (format / print / open) and example fixtures (19 / 20 / 50 / 120 / ffi-foreign-cvariadic) move to new ..name: []T syntax.
variadic cutover 952dc0e Parser hard-rejects the legacy name: ..T form. specs.md documents ..name: []T as the surface syntax.
issue-0050 xfail ec2a99a Generic-mono path (monomorphizeFunction) leaks the outer pack-fn's pack_arg_types into the generic's body lowering — args.len constant-folds to the wrong arity per examples/175-generic-fn-pack-state-leak.sx.
issue-0050 fix 5316bf7 Same isolation pattern as 0048 applied to monomorphizeFunction.
5.1.A xfail 3bd6f26 build_block_convert(args: []Type, $ret: Type) -> string undefined — pin output format via examples/176-build-block-convert.sx across 3 void shapes + 1 non-void shape.
5.1.B fix aeb950b Builder added to library/modules/std/objc_block.sx. Emits nested callconv(.c) trampoline + Block literal source.
5.2.A xfail f5342e9 Generic Into(Block) impl absent — Closure(s64, s64) -> void (uncovered by hand-rolled impls) emits the "no Into(Block) for cl_s64_s64__void" diagnostic per examples/177-generic-into-block.sx.
5.2.B fix 165b621 Generic impl Closure(..$args) -> $R added with #insert build_block_convert($args, $R). lowerExpr's .comptime_pack_ref + resolveTypeArg + type_bridge.isTypeShapedAstNode extended so impl-mono $args (pack_bindings) and $R (type_bindings) resolve in both expr and type positions.
5.3 2eaf932 Delete hand-rolled __block_invoke_void + __block_invoke_bool + the two per-shape impls. The generic impl covers both at runtime.

What's now possible end-to-end (from examples/177-generic-into-block.sx):

#import "modules/std/objc_block.sx";

main :: () -> s32 {
    cl := (a: s64, b: s64) => { g_a = a; g_b = b; };
    blk : Block = xx cl;            // generic impl mono'd for
                                    // Closure(s64, s64) -> void
    invoke_fn : (*Block, s64, s64) -> void callconv(.c) = xx blk.invoke;
    invoke_fn(@blk, 10, 20);
    0;
}

The xx cl : Block site monomorphises the generic Into(Block) for Closure(..$args) -> $R impl. Inside the impl mono, #insert build_block_convert($args, $R) evaluates the builder at comptime with $args = [s64, s64] and $R = void, and substitutes the resulting source — a nested __invoke :: (block_self: *Block, arg0: s64, arg1: s64) -> void callconv(.c) { ... } trampoline plus the Block literal that points its invoke slot at @__invoke. Stack-local block layout matches Apple's published spec; UIKit / Foundation consumers can take this directly.

Adding a new closure shape to stdlib used to mean writing a per-signature __block_invoke_<sig> trampoline + a focused Into(Block) for Closure(<sig>) impl. Now: no stdlib edit needed. The generic impl emits per-call-shape on demand.

217/217 example tests + zig build test green.

Known follow-ups (out of scope for step 5):

  • string-typed arg in a generic block trampoline segfaults at runtime — the 16-byte {ptr, len} slice doesn't round-trip through the callconv(.c) ABI cleanly in the generated trampoline. Hand-rolled impls didn't hit this because they pre-dated string-arg shapes. Real closures of shape Closure( string, ...) -> ... are uncommon in Apple block APIs; revisit when a UIKit caller needs it.
  • Step 6 of the pack feature (rewriting print / format to use ..$args: []$T for compile-time arity + type checking instead of ..args: []Any runtime boxing).

M5.A.next.4A.bare — bare $args + dynamic reflection intrinsics (commits c7926422162662, 5 slices in order).

Closes out step 4A. $args referenced bare (without [...]) in expression position evaluates to a comptime []Type slice; type_name(<dynamic-arg>) correctly dispatches via the interp's runtime arm when the argument isn't statically resolvable. Together these are the foundation step 5's generic Into(Block) builder body rests on.

Slice Commit What
4A.bare.1.A c792642 Expected-failing lock-in for bare $args (parser rejection diff).
4A.bare.1.B 5a4a19b Parser makes [ optional after $<pack_name>; new ComptimePackRef AST node + sema no-op arms + lowerExpr arm calling new buildPackSliceValue(arg_types) helper. Helper emits alloca [N x Any], one const_type(arg_tys[i]) per slot, then a {data_ptr, len} slice aggregate. emit_llvm's const_type arm relaxed to silent undef-i64 (storage of Type values in runtime aggregates is harmless; loud bail moves to USE sites).
4A.bare.4.A 95e61d8 Expected-failing lock-in for type_name(list[i]) silently returning "s64" via resolveTypeArg's catch-all else => .s64.
4A.bare.4.B d99c0fd tryLowerReflectionCall splits on new isStaticTypeArg(node) helper. Static args fold to const_string (today's fast path); dynamic args emit callBuiltin(.type_name, [arg_ref]) for the interp's arm. emit_llvm's reflection-builtin arm relaxed to silent undef-i64 — same reasoning as const_type: storage-position misuse is impossible, use-site misuse caught by the interp arm's asTypeId orelse bailDetail.
4A.bare.5 2162662 End-to-end smoke examples/172-pack-builder-smoke.sx. describe(..$args) walks $args at #run time, calls type_name(list[i]) per position. Four call shapes (empty, one-arg, two-arg, four-mixed) verify the full chain works.

What now works end-to-end (from examples/172-pack-builder-smoke.sx):

describe :: (..$args) -> string {
    list := $args;
    s := "[";
    i : s64 = 0;
    while i < list.len {
        if i > 0 { s = concat(s, ", "); }
        s = concat(s, type_name(list[i]));
        i = i + 1;
    }
    s = concat(s, "]");
    return s;
}

#run { print("{}\n", describe(true, 3.14, "x", 99)); }
// → [bool, f64, string, s64]

The pack flows through a real []Type slice value; the loop indexes dynamically; each element's TypeId comes back through the type_name interp arm; the per-position concrete type names are joined into a string. All at interp time inside #run. No silent paths anywhere.

Known follow-ups (not blocking step 5):

  • type_eq / has_impl dynamic-arg dispatch — should follow the same isStaticTypeArg split that type_name got in 4A.bare.4.B. Today their dynamic-arg case still silently folds via the same resolveTypeArg .s64 fall-through. Wire when a real use case needs them.
  • has_impl interp arm — still bails "not yet wired". Needs a protocol-map snapshot on Interpreter.init.
  • any_to_string's case type: in stdlib still uses xx val to string. Once .type_tag flows into a print path, the bitcast guard fires loudly — fix is to replace with type_name(val) once value-form type_name lowers through the dynamic path.

212/212 example tests + zig build test green.

Step 5 (generic Into(Block) impl) is now fully unblocked on the type-system side.


M5.A.next.4 — activate Value.type_tag (Type as a first-class value) (commits ac60d98, 9600ba5, 55c72af, fd03b58 — 4 slices).

Activated the dormant Value.type_tag(TypeId) variant in the interp by the book — no silent-error budget violations, explicit construction path through a new IR opcode, kind-honest helpers, source-language $args[$i] in expression position.

Slice Commit What
4.0 foundation ac60d98 New Op.const_type: TypeId opcode (dedicated, never piggybacks on const_int). Interp emits Value.type_tag(tid). emit_llvm bails loudly (Type is comptime-only; LLVM never sees one). Value.asTypeId() ?TypeId helper. evalCmp arm for .type_tag, .type_tag — TypeId equality. Mixed .type_tag vs .int falls through to typeErrorDetail. Zig unit tests confirm the variant.
4.1 reflection arms 9600ba5 BuiltinId.type_name / .type_eq / .has_impl for the interp-time fallback when lowering can't fold the call statically. Static-arg calls keep the existing tryLowerReflectionCall const-emission fast path. has_impl interp arm bails with "not yet wired" — interp-time has_impl needs a queryable snapshot of the host's protocol maps (its own follow-up). emit_llvm bails loudly on all three (comptime-only).
4.2 audit + bitcast guard 55c72af box_any/unbox_any audit: layout was already correct (tag stays .int; value field can be .type_tag). bitcast interp arm guards against .type_tag → <non-Any, non-identity> casts — catches the xx val to string shape in any_to_string's case type: arm that pre-dates type_tag and would silently mis-coerce.
4.3 source construction fd03b58 Parser accepts $<pack>[<int_literal>] in expression position (yields the same pack_index_type_expr AST node already used in type positions in step 3). Lowering: lowerExpr arm emits const_type(arg_tys[index]); resolveTypeArg arm reads pack_arg_types[name][index] directly so lower-time fold paths (tryLowerReflectionCall, tryConstBoolCondition) see the bound TypeId rather than falling through to the .s64 silent-arm default.

Audit summary — every Value-switch in interp.zig was checked for silent fall-through. Findings:

  • All existing else arms are either already bailDetail / error.TypeError (loud) or pass-through helpers where transit- unchanged is semantically correct for .type_tag.
  • box_any tag field stays .int; value field can carry any Value kind including .type_tag. No changes needed.
  • asInt/asFloat/asBool/asString keep returning null for .type_tag — no silent coercion to int just because TypeId is internally an int.
  • Comparison op cmp_eq got an explicit .type_tag, .type_tag arm.
  • Coercion op bitcast got an explicit bail arm for .type_tag → <runtime kind> to catch any stale xx val to string paths.

What's now possible end-to-end (from examples/169-pack-value-dispatch.sx):

show :: (..$args) -> string => type_name($args[0]);
show(42)    // "s64"
show("hi")  // "string"

describe :: (..$args) -> string {
    inline if type_eq($args[0], s64) { return "got s64"; }
    inline if type_eq($args[0], string) { return "got string"; }
    ...
}

$args[0] as a value flows through const_typeValue.type_tagtype_name/type_eq (lower-time fold via resolveTypeArg) without losing its kind anywhere.

Known follow-ups:

  • has_impl interp arm currently bails. Needs a protocol-map snapshot on Interpreter.init.
  • any_to_string's case type: arm in stdlib still does xx val to string — pre-type_tag shape. Once .type_tag flows into a print/format path, the bitcast guard fires. Fix is to replace with type_name(val) once the lowering supports value-shaped type_name.
  • $args (bare, without indexing) as a []Type value — needed by full step-5 builder bodies. Single-element access works; whole-slice access deferred.

209/209 example tests + zig build test green.


M5.A.next.3 — type-position $args[$i] + reflection intrinsics (commits 69dcee88b457ff, 5 total).

Step 3 of the variadic heterogeneous type packs feature. $args[$i] (literal index) now parses + resolves in every type position. Three comptime intrinsics — type_name, type_eq, has_impl — let pack-fn bodies branch on type identity / protocol membership at compile time.

Commit Slice
69dcee8 3a.A lock-in: pre-fix parse error for $args[$i] in type positions
3df58fe 3a.B fix: parser + AST (PackIndexTypeExpr) + resolveTypeWithBindings arm + sema no-op cases
9137f41 3a.C: extend resolution to fn-pointer type literals (fp : (*void, $args[0]) -> $args[1] = ...)
8b457ff 3b: type_eq + has_impl intrinsics, both wired through tryConstBoolCondition for inline if folding

What works:

  • (..$args) -> $args[0] — return type position.
  • x : $args[1] = args[1] — local-var annotation.
  • fp : (*void, $args[0]) -> $args[1] = handler; — fn-pointer type literal (the shape step 5's generic trampoline body needs).
  • inline if type_eq($args[0], s64) { ... } (when the $args[0] argument is in a type position — type_eq reads call args via resolveTypeArg which routes to resolveTypeWithBindings).
  • has_impl(Hash, s64) (plain protocols).
  • has_impl(Into(Block), s64) (parameterised protocols).

New tests:

  • examples/165-pack-type-position.sx — return type + local var annotation; two heterogeneous call shapes (s64+string, string+s64) confirm distinct monos.
  • examples/166-pack-type-position-three.sxargs[2] (third element) as return type across three (s64,s64,string), (bool,f64,s64), (string,string,bool) shapes.
  • examples/167-pack-type-fnptr.sx — fn-pointer type literal with $args[$i] in both param + return positions.
  • examples/168-pack-reflection-intrinsics.sx — type_name, type_eq (with inline-if folding), has_impl for both plain (Allocator/CAllocator) and parameterised (custom Wrap(s64) for s32) protocols.

Out of scope (deferred):

  • $args[$i] in EXPRESSION position (the parser only accepts it in type positions today — type_eq($args[0], s64) works because the call-arg path resolves through resolveTypeArg, but bare $args[0] as a value would need an extra parser arm).
  • $args[$i] in struct field types.
  • has_impl static-only false-negative for protocols whose thunks haven't been instantiated yet (relies on protocol_thunk_map for plain protocols; a more robust walk over fn_ast_map["<T>.<method>"] is deferred).
  • LSP "undefined variable" warnings on type names passed to reflection intrinsics (cosmetic; sema doesn't know these builtins accept types as args).

Step 5 (generic Into(Block) for Closure(..$args) -> $R impl in stdlib) is now unblocked from the type-system side. The trampoline body can finally interpolate (*void, $args[0], $args[1], ...) -> $R per-position types.

208/208 example tests + zig build test green.


issue-0046 fix — save/restore outer state in createComptimeFunction (commit 248d6e6, lock-in 13efc56).

Closes the nested-comptime-call + return bug. The pack-fn face was incidentally fixed by step 2b's mono refactor (pack-fn calls now bypass the inline-return-slot setup that leaked into nested comptime). The plain ($x: s32) comptime face stayed on the inline path until this fix.

createComptimeFunction wraps a comptime expression into a fresh fn that the interp executes in isolation. The wrapper must not inherit the enclosing call's lowering state. Pre-fix only func / current_block / inst_counter / scope / current_ctx_ref were saved. The fix adds eight more:

  • inline_return_target — outer lowerComptimeCall's return-routing slot. Was leaking into the wrapper body and routing the wrapper's ret into a different fn's basic block; the interp executed garbage IR and tripped a null pointer store at storeAtRawPtr.
  • pack_arg_nodes / pack_param_count / pack_arg_types — active during pack-mono body lowering. Pack-fn face of 0046 was already fixed by step 2b moving pack-fn calls off the inline path; these saves close a latent cross-contamination if a future pack-mono body invokes the comptime interp.
  • comptime_param_nodes — outer lowerComptimeCall's $fmt-style substitution map.
  • block_terminated / target_type / func_defer_base — fn-local flags the wrapper's lowering needs fresh.

examples/issue-0046.sx (regression test): helper :: ($x: s32) -> s64 { print("inside\n"); return 42; } called from main. Pre-fix: interp panic at storeAtRawPtr. Post-fix: prints "inside" then "n=42".

204/204 example tests + zig build test green.


M5.A.next.2b.fu1fu4 — all four step-2b follow-ups landed (commits c917f92, 2e0b97a, d30d566, 159f898 + lock-ins).

The four deferred follow-ups from step 2b's initial landing are now closed. Pack-fns are functionally complete on the mono path; the only remaining gaps are issue-0046 (nested comptime calls) and step 3+ work (type-position $args[$i] for the stdlib payoff).

FU Commit What it does
#2 generic $R c917f92 + 2e0b97a monomorphizePackFn infers ret_ty from the body's tail expression / first explicit return when the declared type is generic. New pack_arg_types map gives inferExprType direct access to per-position pack arg types (avoids the synthesized-ident detour). New diagPackIndexOOB emits "pack index N out of bounds: 'pack' has M elements" instead of the misleading "unresolved 'args'" fall-through.
#3 bare args d30d566 materialisePackSlice builds an []Any slice value for the pack name inside the mono — each pack param boxed via boxAny, stored in a stack [N x Any] array. Bare args resolves as a runtime slice for forwarding to []Any-typed helpers. Per-position type info is lost via Any boxing; literal-indexed access still routes through packArgNodeAt and keeps concrete types.
#4 runtime indexing d30d566 (shared) args[<runtime_int>] lowers through the standard slice path against the materialised []Any. Element type Any — inherent to runtime indexing into a heterogeneous pack.
#1 mixed comptime+pack 159f898 isPackFn relaxed to "trailing pack + any non-pack comptime params". lowerPackFnCall folds comptime VALUES into the mangle (__ct_<value> segment per non-pack comptime); monomorphizePackFn binds each comptime non-pack param both as a comptime_param_nodes entry AND as a runtime local. appendComptimeValueMangle hashes strings, formats int/bool/float for stable mangles.

New tests:

  • examples/159-pack-generic-ret.sx — basic generic $R.
  • examples/160-pack-hetero-ret.sxargs[2] with mixed-type pack returns the third arg's concrete type ("hello").
  • examples/161-pack-index-oob.sx — OOB pack index emits a focused diagnostic.
  • examples/162-pack-bare-args.sx — bare args forwarded to log_count(items: []Any).
  • examples/163-pack-runtime-index.sxwhile i < args.len { args[i] } over a 4-arg pack.
  • examples/164-pack-mixed-comptime.sxtagged($tag: s32, ..$args) called with different tag values gets distinct monos (tagged__ct_7__pack_*, tagged__ct_9__pack).

203/203 example tests + zig build test green. Step 6's stdlib print / format refactor is now unblocked from the type-system side (mixed comptime+pack works); step 5 still needs step 3's $args[$i] in type positions for the generic Into(Block) impl body.


M5.A.next.2b — per-call-shape monomorphisation for pack-fns (commit 7989618).

Pack-fns (detected by isPackFn(fd) — last param is the only comptime param AND is variadic) now emit ONE shared mono per unique call-site signature. Repeat calls with the same arg-type tuple share the mono; distinct shapes get distinct symbols. Pre-2b each call inlined a fresh body copy into the caller's basic block.

examples/158-pack-mono-dedup.sx confirms end-to-end: count(), count(1), count(2), count(1,2,3), count("x", true) produces 0 1 1 3 2 at runtime AND emits exactly 4 monos in IR — the two s64 calls share one mono.

Plumbing in src/ir/lower.zig:

  • isPackFn(fd) — true when the only comptime param is a trailing pack. Mixed ($fmt, ..$args) shapes stay on the inline lowerComptimeCall path (different substitution mechanism for the non-pack comptime; deferred).
  • lowerPackFnCall — builds mangled name <fn_name>__pack__<arg_types>, cache-checks lowered_functions, calls monomorphizePackFn on miss, emits a direct call.
  • monomorphizePackFn — mirrors monomorphizeFunction's save/restore + param/scope setup, with N synthesised pack params (__pack_<name>_<i>) and AST-ident substitution installed via pack_arg_nodes. pack_param_count makes args.len resolve to the comptime N via new intercepts in lowerFieldAccess + inferExprType. inline_return_target is nulled out so the mono body emits real ret X instead of the inline-slot routing — it's a real fn now.
  • Routed at three call sites: each hasComptimeParams(fd) → lowerComptimeCall now first checks isPackFn and routes to lowerPackFnCall when true.

Lifetime gotcha caught and fixed: Function.init stores params.items by reference (no copy). The local ArrayList(Function.Param) must NOT be deinit'd — matches the leak convention in monomorphizeFunction. Symptom of getting this wrong: 0xAAAAAAAA poison-pattern TypeIds in coerceCallArgs.

examples/156-pack-typed-index.sx (typed indexing) and examples/157-pack-if-return.sx (control flow) continue to pass unchanged on the new path.

Out of scope (deferred to follow-up slices):

  • Mixed $fmt + ..$args shapes.
  • Generic $R return types.
  • Bare args reference (passing the pack-slice as a whole).
  • args[<runtime_int>] non-literal indexing.

197/197 example tests + zig build test green.


M5.A.next.2a.D — inline-return uses CFG terminator, not block_terminated (commit e6d6903, lock-in 6b7a66b).

Fixes a control-flow regression in issue-0045's original fix (commit 9e78790). The original set block_terminated = true after each inline return X; to skip dead code in the inlined body — but the flag leaked past structured control flow. A body shaped if cond { return X; }; return Y; had its trailing return Y; short-circuited at lowerBlockValue's if (self.block_terminated) return null; check. For the false- condition path, the slot was never written → load read uninitialised stack memory.

Reshaped to the classical SSA "return-done block" pattern:

  • InlineReturnInfo gains a done_bb: BlockId field — a fresh basic block allocated by lowerComptimeCall per comptime-call instance.
  • lowerReturn's inline path stores into the slot, drains defers, and emits br done_bb. The basic block's terminator carries the "no fall-through" signal; the block_terminated flag is no longer touched.
  • lowerComptimeCall lowers the body, then unconditionally switches to done_bb and loads the slot. Tail-expression bodies (rare when has_return is true) get a synthetic store
    • br so the CFG stays well-formed.

examples/157-pack-if-return.sx flips from 8354116000 (the uninit slot load) to -1. A three-way classify(..$args) smoke confirms multi-return-path bodies work for any of the three branches; defer-with-return still fires the defer at the correct exit.

issue-0046 (nested print inside ..$args body that also returns) is unrelated to this fix and is still open — same two faces as filed: interp panic for plain comptime, "unresolved 'result'" for pack-fn.

196/196 example tests + zig build test green.


M5.A.next.2a.B — pack typed indexing: args[$i] substitutes call arg (commit cd36784, lock-in 223ec3d).

Step 2 first slice of the pack feature. Pack-fn bodies that index the pack via args[<int_literal>] now resolve to the i-th call-site argument's lowered value directly, propagating the call arg's concrete type instead of the boxed Any that the []Any slice path returns. Lets bodies do field access / typed assignments / further indexing on pack elements without manual unboxing.

New plumbing in src/ir/lower.zig:

  • pack_arg_nodes: ?StringHashMap([]const *const Node) on Lowering — maps pack param name to the slice of call-site arg AST nodes.
  • lowerComptimeCall populates the map when the variadic param is heterogeneous (is_variadic AND is_comptime — i.e. ..$args). Plain args: ..Any skips registration so stdlib's format/print continue boxing through Any. Saved/restored across nested calls.
  • packArgNodeAt(ie) returns the call-arg node when an index_expr matches <pack_name>[<comptime_int_literal>] with the index in range.
  • lowerIndexExpr and inferExprType's index_expr arm both check packArgNodeAt first so the concrete type flows through field access and typed-assignment paths.

examples/156-pack-typed-index.sx flips from "field 'x' not found on type 'Any'" to 7args[0].x on a struct-typed call arg resolves through Point.x correctly.

Out of scope:

  • Non-literal comptime indices (args[$i] where $i is a comptime expression with binding).
  • $args[$i] in type positions (step 3).
  • Per-mono mangling (monomorphisation stays inline-only).
  • Nested comptime calls bug surfaces here too: a pack-fn body that calls print(...) AND has a return X; trips "unresolved 'result'" because nested comptime inlining loses the scope where stdlib's #insert build_format declared result. Same class as the helper :: ($x: s32) -> s64 { print(...); return 42; } pattern; pre-dates step 2. Worth filing if step 2's later slices need it; today's typed-indexing test exercises only field access and arithmetic, no nested print.

195/195 example tests + zig build test green.


issue-0045 fix — inline-return slot for comptime-call bodies (commit 9e78790, lock-in 3d32ab0).

Surfaced by probing step-2 territory of the pack feature: any comptime fn (is_comptime param, non-void return) with a block body containing return X; trips LLVM's "Terminator found in the middle of a basic block" verifier. lowerComptimeCall inlines the body's statements directly into the caller, and lowerReturn emits ret into the caller's basic block — but the caller still has trailing instructions.

Root cause was broader than packs: format/print use arrow form (=> expr) or #insert-only bodies, so no stdlib comptime fn took the return-with-trailing-statements path. Step 1.b made ..$args parseable; the natural smoke test foo :: (..$args) -> s64 { return 42; } was the first body to hit it.

Fix in src/ir/lower.zig:

  • New inline_return_target: ?InlineReturnInfo on Lowering. InlineReturnInfo carries a result slot Ref + the ret_ty.
  • lowerComptimeCall calls fnBodyHasReturn to scan the body; when true, it allocates a slot, installs it as inline_return_target, lowers the body, and either returns the tail expression value OR loads the slot when block_terminated is set. Pure tail-expression bodies skip the slot entirely — keeps the common #insert-based path unchanged.
  • lowerReturn checks inline_return_target first: when set, stores the coerced value into the slot, drains pending defers, sets block_terminated = true, and returns without emitting ret. Otherwise the standard ret path runs.

Regression test examples/issue-0045.sx flips from the LLVM verifier crash to 42. 194/194 example tests + zig build test green.


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

  • 217/217 example tests pass; zig build test green.
  • Step 5 of the variadic heterogeneous type packs feature done end-to-end. Generic Into(Block) for Closure(..$args) -> $R impl in stdlib emits per-call-shape __invoke trampoline + Block literal via #insert build_block_convert($args, $R). Hand-rolled __block_invoke_void / __block_invoke_bool deleted; examples/95 / 96 route through the generic unchanged.
  • Three compiler-bug fixes landed alongside step 5 — issues 0048 (lazyLowerFunction pack-state leak), 0049 (new-form variadic ..name: []T cross-module emit crash), 0050 (monomorphizeFunction pack-state leak). Each is captured by a focused regression test (examples/173 / 174 / 175).
  • Legacy variadic syntax name: ..T rejected at parse time; stdlib (path_join / format / print / open) and example fixtures migrated to ..name: []T. specs.md updated.
  • Phase 3.0/3.1/3.2 + M1.0M1.3 + M2.1M2.3 + M3 + M4.0 + M4.A all landed.
  • Pack feature step 1 done (1c.A → 1d.B; commits bb6eca608feb60).
  • Pack feature step 2 done — typed args[$i] at literal indices (cd36784) + CFG terminator fix (e6d6903) + per-call-shape mono (7989618).
  • Pack feature step-2 follow-ups all landed: generic $R (c917f92, 2e0b97a), bare args + runtime indexing (d30d566), mixed comptime+pack (159f898). Pack-fns are functionally complete on the mono path.
  • issue-0045 (comptime-fn-with-return verifier crash) fixed (9e78790).
  • issue-0046 (nested-comptime-call + return) FIXED (248d6e6) — createComptimeFunction now saves/restores outer state.
  • issue-0047 (#run stderr vs runtime stdout split) FILED.
  • Pack feature step 3 done — type-position $args[$i] + reflection intrinsics (type_name, type_eq, has_impl).
  • Pack feature step 4.04.3 done — Value.type_tag activated honestly; source-language $args[$i] in expression position yields a comptime Type value end-to-end.
  • Pack feature step 4A.bare done — bare $args evaluates to a comptime []Type slice; dynamic type_name(list[i]) lowers through the interp's runtime arm via a new isStaticTypeArg split in tryLowerReflectionCall.
  • iOS-sim chess running end-to-end (verified post-step-2b screencap).
  • Chess on macOS / iOS-sim / Android all build and run.

Pack feature — next slice options

Steps 1, 2 (+ four follow-ups), and 3 all done. Pack-fns are functionally complete: typed indexing, generic returns, heterogeneous picks, OOB diagnostics, bare/runtime args access, mixed comptime+pack, $args[$i] in type positions, type-reflection intrinsics.

Step 4A done end-to-end (4.0 → 4.3 → 4A.bare). Step 4 remaining:

  • 4B compile_error(fmt, args) comptime intrinsic — raise a build-time diagnostic from inside a builder. Small commit set; not blocking step 5 but useful for builder error paths.
  • type_eq / has_impl dynamic-arg dispatch — follow the same isStaticTypeArg split that type_name got in 4A.bare.4.B.
  • has_impl interp arm — currently bails, needs a protocol- map snapshot on Interpreter.init.

Step 5 (generic Into(Block) impl) — the visible end-user payoff. Replaces stdlib's per-signature hand-rolled Into impls with ONE generic that the compiler emits per-call-shape. Body uses $args[$i] in fn-pointer type positions for the trampoline signature (step 3 unblocked) + const_type Type values in expression position (step 4 unblocked) + a single #insert build_block_convert(...) emission. Needs bare-$args (4A) to land first, plus a builder fn that emits the trampoline

  • Block literal source string.

Step 6 (stdlib print / format refactor) — rewrite the existing ($fmt: string, args: ..Any) signatures to use the new pack feature. Compile-time arity and type checking instead of runtime Any boxing.

Outstanding items not blocking the next slice:

  • Non-literal comptime args in mixed-mode pack-fns (degrades to a ? mangle segment today).
  • LSP "undefined variable" warnings on type-name args to reflection intrinsics (cosmetic).
  • any_to_string's case type: arm in stdlib uses xx val to string — pre-type_tag shape. Once .type_tag flows into a print/format path, the bitcast guard fires. Fix is to replace with type_name(val) once value-form type_name is wired through tryLowerReflectionCall.

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.