Files
sx/current/CHECKPOINT-FFI.md
agra 6d258ad82b ffi M1.2 A.1 follow-up: struct args/returns in Obj-C type encoding
`appendObjcEncoding` previously bailed on `.@"struct"`, which blocked
sx-defined `#objc_class` methods from declaring CGPoint / CGRect /
NSRange-shape signatures — the `class_addMethod` registration path
would emit a "type kind not yet supported by Obj-C encoding"
diagnostic. The helper now emits Apple's `{Name=field0field1...}`
form recursively, with a small `ObjcEncodingStack` (cap 16) that
breaks transitive struct→struct cycles by emitting the abbreviated
`{Name}` form instead of recursing forever.

`{Point=dd}`, `{_NSRange=QQ}`, `{CGRect={CGPoint=dd}{CGSize=dd}}`
all flow through the existing `objc_msg_send` + `class_addMethod`
path with no further plumbing.

Tests:
- `lower.test.zig` gains four cases: optional unwrap (single + nested),
  flat struct (CGPoint, NSRange shape), nested struct (CGRect with
  CGPoint+CGSize), bringing the helper's test coverage from
  primitives + pointers to the full encoding table.
- `examples/ffi-objc-defined-class-02-struct-encoding.sx` exercises
  a sx-defined `SxMover` class with `goto(p: Point)` setter and
  `here() -> Point` getter end-to-end on macOS; the IR snapshot
  confirms `v@:{Point=dd}` and `{Point=dd}@:` land in
  `OBJC_METH_VAR_TYPE_` constants wired to `class_addMethod`.

Checkpoint cleanup: the "Next step (M1.2 A.1 — type-encoding
derivation table)" header in CHECKPOINT-FFI.md was stale (A.1
shipped in 6cc016c; A.0–A.7 all done; commit list now linked).
The encoding table stays as reference material.

224/224 example tests pass; zig build test green.
2026-05-28 14:24:02 +03:00

2028 lines
121 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `3bd6f26``2eaf932`, 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`):
```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 `c792642``2162662`, 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`):
```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`):
```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_type` → `Value.type_tag`
→ `type_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 `69dcee8` → `8b457ff`, 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.sx` — `args[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.sx` — `args[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.sx` — `while i < args.len
{ args[i] }` over a 4-arg pack.
- `examples/164-pack-mixed-comptime.sx` — `tagged($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
`return`s) 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 `7` — `args[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](../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.
```zig
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
sx_ffi_sum_ints :: (n: s32, args: ..s32) -> s64 #foreign;
main :: () -> s32 {
print("{}\n", sx_ffi_sum_ints(3, 10, 20, 30)); // → 60
}
```
Three pieces shipped together (no separate cadence slices — the test
locks in the green state in one commit):
1. **IR + emit_llvm**. `Function.is_variadic` ([src/ir/inst.zig](src/ir/inst.zig));
`declareFunction` ([src/ir/lower.zig:671](src/ir/lower.zig#L671))
detects a foreign+variadic-tail decl, drops the variadic param
from the IR signature, and sets the flag. `emitFunctionDecl`
([src/ir/emit_llvm.zig:682](src/ir/emit_llvm.zig#L682)) passes
`is_var_arg=1` to `LLVMFunctionType` when the flag is set; the
per-call-site `LLVMBuildCall2` already passes all args through, so
extras land in the variadic slot via the linker-fixed C ABI.
2. **Skip slice-packing**. `packVariadicCallArgs`
([src/ir/lower.zig:6354](src/ir/lower.zig#L6354)) early-outs for
foreign+variadic so extras stay as individual refs instead of
getting boxed into a typed slice.
3. **C default argument promotion**. New `promoteCVariadicArgs`
([src/ir/lower.zig](src/ir/lower.zig)) applies the standard
promotions to args past the fixed param count: `bool/s8/s16/u8/u16
→ s32` via sext/zext, `f32 → f64` via fpext. Wired into the two
`lowerCall` paths right after `coerceCallArgs`.
`examples/ffi-foreign-cvariadic.sx` + `.c` lock the matrix end-to-end:
`sum_ints(3, 10, 20, 30) → 60`, `sum_ints(0) → 0`, `avg_doubles(2,
1.5, 2.5) → 2.0`, `avg_doubles(3, 1.0, 2.0, 3.0) → 2.0`, and a
null-terminated `count_args` chain of `*u8` strings → `3`. All four
return shapes (s64 / f64 / s32) and three element types (s32 / f64 /
*u8) exercise the variadic-slot ABI through the C `va_arg` machinery
in the .c helper.
`examples/issue-0043.sx` retired (placeholder stub had no expected
output; the focused feature example above is the new pin point).
150 host + 10 cross-compile tests pass. Stale snapshots re-pinned in
the same commit: 12 IR/.txt files that drifted from in-progress
std.sx additions (`xml_escape`, `path_join`) and the
`BuildOptions.set_post_link_*` work. All diffs were verified to be
either new dead extern decls, string-slot renumbering, or UB-driven
struct fields — no semantic changes.
Recent landings (working back from the head of the Log section):
| When | What |
|------------|-------------------------------------------------------------------|
| 2026-05-22 | issue-0043 — `#foreign` C-variadic `args: ..T` end-to-end |
| 2026-05-21 | Phase 3 step 3.0 — Obj-C DSL dispatch + default selector mangling |
| 2026-05-20 | JNI byte/short/char return + varargs promotion (sext/zext/fpext) |
| 2026-05-20 | JNI parameter validator lifted to lowering with source spans |
| 2026-05-20 | JNI return-type validator lifted from emit_llvm into lowering |
| 2026-05-20 | Silent-`undef` sweep — ~25 emit_llvm sites → diagnostic + undef |
| 2026-05-20 | Chess-on-Pixel touch fix (missing `.f32` row in JNI Call-T switch) |
| 2026-05-20 | Chess-on-Pixel size fix (android.sx refactored to zero globals) |
| 2026-05-20 | issue-0044 — `#jni_main` body deferred-type-fn lowering order |
**Phase 1.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 bb6eca6 → 08feb60).
- 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`](../src/ir/lower.zig)).
- 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](../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](../src/ir/lower.zig#L8617) walks
`foreign_class_map` + `findForeignMethodInChain` for the method's
declared param types. Regression test
[examples/issue-0044.sx](../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`.
## M1.2 A.0A.7 complete (historical planning block follows)
A.0 through A.7 all shipped — see Log entries `61a2593` (A.0),
`6cc016c` (A.1), `7b98b3a` (A.2a), `ae1072d` (A.2b), `659cdc2`
(A.2c + A.3), `b98a22e` (A.4), `c2178c0` (A.4b.i), `c0b338e`
(A.4b.ii), `8757257` (A.4b.iii), `a1736f3` (A.5), `c107aa4` (A.6),
`51277af` (A.7), plus `f75923a` (uikit struct field types +
optional-in-encoding follow-up). The Apple type-encoding helper
table below stays here as reference material — every encoding it
describes is implemented in [src/ir/lower.zig](../src/ir/lower.zig)
`appendObjcEncoding` and exercised by `lower.test.zig` +
`examples/ffi-objc-defined-class-{01-instance,02-struct-encoding}.sx`.
Apple's runtime DSL encoding table:
- `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=field0field1...}`, nested + cycle-broken.
## 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_bundle` — `NSBundle.mainBundle.resourcePath` chain (2× `*void` returns through one `msg_o` cast) | done (3518d0e) |
| 1.27 | `uikit_read_screen_scale` — `UIScreen.mainScreen.nativeScale` (class-method `*void` + instance-method `f64`); first standalone `#objc_call(f64)` exercise | done (4844f57) |
| 1.28 | `show_keyboard` / `hide_keyboard` pair — `becomeFirstResponder` / `resignFirstResponder` (BOOL returns, discarded). Initially landed as `#objc_call(u8)`; corrected to `#objc_call(bool)` in follow-up ee53348. Runtime-verified by the locked-in test `examples/ffi-objc-call-11-bool-return.sx` (e52f9f2) — two BOOL-returning IMPs via `class_addMethod`. | done |
| 1.29 | `uikit_create_gl_context` — `alloc` / `initWithAPI:` / `setCurrentContext:` + duplicate of 1.27's screen-scale read | done |
| 1.30 | `uikit_subscribe_keyboard_notifications` — first standalone 4-keyword selector exercise (`addObserver:selector:name:object:`) | done |
| 1.31 | `uikit_scene_will_connect_ios` — biggest cluster; the iOS scene-lifecycle entry. UIWindow / UIViewController / SxGLView wiring; EAGL drawable-properties dict build; `nativeScale` + `setContentScaleFactor:` DPI path; `displayLinkWithTarget:selector:` + run-loop install. Exercises every return shape used in uikit.sx. Net -44 lines (104 → 60). | done (b3558c3) |
| 1.32 | `uikit_keyboard_will_change_frame` — `userInfo` / `objectForKey:` / `CGRectValue` / `doubleValue` / `unsignedLongValue` / `screen.bounds`. First standalone exercise of `#objc_call(CGRect)` (HFA, structurally equivalent to UIEdgeInsets) and `#objc_call(u64)` (LLVM-equivalent to s64). Net -14 lines. Runtime-verified by the locked-in test `examples/ffi-objc-call-12-rect-u64-returns.sx` (ac78490). | done (e1d300c) |
| 1.33 | **uikit.sx sweep — all remaining dispatch sites.** `renderbufferStorage:fromDrawable:` (bool, GL setup); `presentRenderbuffer:` (bool, every frame); `targetTimestamp` / `duration` (f64, every frame in `uikit_gl_view_tick`); `bounds` (CGRect, `uikit_compute_layer_pixel_size`); `locationInView:` (CGPoint HFA, every touch); `anyObject` (*void, every touch). First standalone `#objc_call(CGPoint)` exercise. Net -15 lines. Runtime-verified end-to-end: tapped a black pawn in iOS-sim chess and the move played correctly (1...d5, 2...d4). | done |
Verification per cluster: zig build / zig test / run_examples /
cross_compile all green; chess `sx build --target ios-sim main.sx`
and `--target android main.sx` both compile clean.
## Phase 1C in progress (`#jni_call` codegen)
Phase 1D for uikit.sx is done (`-98` lines, zero typed-cast dispatch
sites). Phase 1C is now active — the JNI parallel to 1.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 `GetObjectClass` → `NewGlobalRef` (slot 21) → `GetMethodID` on miss. Two literal sites now share one slot pair. | done (0d883b4) |
| 1.18 | `#jni_call(s32)` → CallIntMethod (vtable slot 49). One arm added to the `call_method_offset` switch; reuses the 1.17 cache. | done (1d7ea72 xfail + ebcfe4c fix) |
| 1.18+ | Lift JNI vtable offsets into a `const Jni` named-constants struct. Pre-loaded Object/Boolean/Long/Float/Double slots so 1.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. | done (xfail + b5694cc fix) |
| 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.body` → `methods: []const JniMethodDecl`. parseJniClassDecl loops over body items parsing each `name :: (self: *Self, args...) -> Ret;`. Sema/lower still treat the decl as an opaque type alias. | done (f5da453 xfail + a2a2e83 fix) |
| 2.3 | `static name :: (args...) -> Ret;` body items. `JniMethodDecl` gains `is_static: bool`. Body loop recognises a context-sensitive `static` identifier prefix (still a plain identifier elsewhere). | done (082ef43 xfail + ecce8cd fix) |
| 2.4 | `#extends Alias;` / `#implements Alias;` body items. Two new lexer tokens `hash_extends` / `hash_implements`; `JniClassDecl.methods` refactored into `members: []const JniClassMember` tagged union (`method` / `extends` / `implements` variants); body loop dispatches on leading token. | done (e225adb xfail + a5c6f75 fix) |
| 2.5 | `name: Type;` field body items. New `JniFieldDecl` struct; `JniClassMember` gains `field` variant. Body loop branches on next-after-name: `:` → field path, `::` → method path. `static` fields explicitly errored. | done (1dee9ba xfail + a703eee fix) |
| 2.6 | `#jni_method_descriptor("(Sig)Ret")` per-method JNI descriptor override. New `hash_jni_method_descriptor` lexer token; method decl gains optional override field consumed after the return type. Initially proposed as `#desc`, renamed for consistency with the `#jni_*` directive family. | done (0ed4799 xfail + 11021d8 fix) |
| 2.7 | Parser accepts the other six type-introducer directive forms (`#jni_interface`, `#objc_class`, `#objc_protocol`, `#swift_class`, `#swift_struct`, `#swift_protocol`) with the same body grammar. AST refactor: `JniClassDecl` → `ForeignClassDecl` carrying a `runtime: ForeignRuntime` enum discriminator; `JniMethodDecl`/`JniFieldDecl`/`JniClassMember` renamed to `Foreign*`; AST variant `jni_class_decl` → `foreign_class_decl`. `parseForeignClassDecl` takes the runtime as a parameter; `foreignRuntimeForCurrent` maps each directive token to its variant. Sema arm renamed; no codegen yet. | done (dc3821a xfail + 5fd8e0f fix) |
## Phase 2A complete (parser + AST)
All seven type-introducer directive forms parse with the shared body
grammar (instance/static methods, fields, `#extends`, `#implements`,
`#jni_method_descriptor`). Sema registers each as an opaque type
alias; no lowering yet.
## Phase 2B in progress (signature derivation)
| # | What | Status |
|-----|---|---|
| 2.8 | `src/ir/jni_descriptor.zig` + `.test.zig`. `writeType` appends one JNI descriptor for an sx type AST node; `deriveMethod` returns the full `(args)ret` descriptor for a `ForeignMethodDecl`, skipping the implicit `self` on instance methods. `Context.enclosing_path` resolves `*Self` to its `L<path>;` form. Primitive table-driven (void→V, bool→Z, s8/u8→B, s16→S, u16→C, s32→I, s64→J, f32→F, f64→D); arrays `[]T`/`[*]T`/`[N]T` → `[<elem>`. Cross-class `*Foo` → explicit error (lands in 2.9). 10 unit tests pass. **Cadence note**: landed as single commit since internal compiler functions don't have a sx-level snapshot surface yet — the rule re-applies at 2.11 where call-site lowering becomes end-to-end observable. | done (21c4906) |
| 2.9 | Cross-class `*Foo` resolves via `Context.classes: ?*const ClassRegistry` (a `StringHashMap` of sx alias → foreign path). `*Self` and `*Foo` share one code path. Retired `CrossClassRefNotYetSupported` in favour of `UnknownClassAlias`, which fires for both "no registry provided" and "alias not in registry". | done (5188265) |
| 2.10 | `deriveMethod` short-circuits to the `jni_descriptor_override` (2.6 escape-hatch) when present, returning the override verbatim through an `allocator.dupe`. Bypasses normal derivation entirely — including resolution failures, which lets users escape `UnknownClassAlias` errors for synthetic-method cases. | done (ca840ff) |
## Phase 2B complete (signature derivation)
`src/ir/jni_descriptor.zig` handles every shape the parser can hand it:
- Primitive types: `void/bool/s8..s64/u8/u16/f32/f64` → JNI single-char.
- Arrays / slices / many-pointers: `[<elem>` (recursive).
- `*Self` → `L<enclosing_path>;`.
- `*Foo` → looks up Foo's foreign path in the supplied registry.
- `#jni_method_descriptor("...")` override → returns the literal verbatim.
- Cross-class miss / no-registry → `UnknownClassAlias`.
15 unit tests cover the matrix. Function is ready for consumption by
call-site lowering (step 2.11+).
## Phase 2 step 2.16 in progress (env scoping)
| # | What | Status |
|-------|---|---|
| 2.16a | Parser + AST + sema accept `#jni_env(env) { body }`. New `hash_jni_env` lexer token; `parsePrimary` dispatches to `parseJniEnvBlock`. AST node `JniEnvBlock { env, body }`. Sema's `analyzeNode` and `findNodeAtOffset` arms recurse through env + body. Lowering treats it as a syntactic wrapper around the block (env evaluated for side effects, body lowers normally). `expectSemicolonAfter` recognises it as block-form so no trailing `;` needed. | done (93adde5 xfail + 5bd2c84 fix) |
| 2.16b | Lexical-direct env resolution + optional env in `#jni_call`. `Lowering` gains a `jni_env_stack: ArrayList(Ref)`; the `jni_env_block` arm pushes/pops around body lowering. `lowerJniCall` disambiguates via position of first string-literal arg (index 1 = omitted, index 2 = explicit) and reads top of stack when omitted. IR snapshot locks in the optimised shape (env flows straight from enclosing scope into `jni_msg_send`; no TL read). | done (e463385 xfail + 022ca31 fix) |
| 2.16c | TL fallback for cross-function helpers — the env scope's value is also written to a thread-local slot, so callees outside the lexical scope read it via `sx_jni_env_tl_get()`. Also collapses `#jni_call` to always-omit-env (explicit-env shape retired). Per-fn `jni_env_stack_base` makes lazy-lowered callees ignore the caller's Refs. **Note**: the TL slot currently lives in a small C runtime helper (`library/vendors/sx_jni_runtime/sx_jni_env_tl.c`) because LLVM ORC JIT's default platform doesn't initialise TLS for objects loaded via `LLVMOrcLLJITAddObjectFile`. The .c file is linked into sx-the-compiler (build.zig) and auto-injected into AOT outputs (`lowering_extra_c_sources` on Compilation). **This is a stopgap** — see "Deferred: sx-native runtime" below. | done (013cf9f xfail + 6a3260f fix) |
## Phase 2C in progress (call-site lowering)
| # | What | Status |
|-------|---|---|
| 2.11 xfail | `act.getWindow()` on a `#jni_class`-typed receiver. Today's sema reports "unresolved: 'getWindow'" because foreign-class members aren't wired into method resolution. Snapshot captured. | done (09e4ec2) |
| 2.11 green | `Lowering` gains `foreign_class_map`; `registerForeignClassDecl` records each alias in the scan pass. `lowerCall`'s method-dispatch arm now checks for foreign-class receivers BEFORE the standard struct-method path, calling new `lowerForeignMethodCall`. That helper looks up the method in `ForeignClassDecl.members`, derives the descriptor via `jni_descriptor.deriveMethod` (with a `ClassRegistry` snapshot of all foreign classes), and emits `jni_msg_send` directly. Env from the enclosing `#jni_env` scope (lexical-direct). Filters by runtime — `jni_class`/`jni_interface` lower; Obj-C/Swift report "not yet supported". The type-bridge's existing fallback (unknown named types → 0-field struct) handles `*Activity` resolution with no additional plumbing. `jni_descriptor` gains `*void → Ljava/lang/Object;` (opaque-jobject default). IR snapshot at `ffi-jni-class-08-call.ir` shows the full slot-interned lowering. | done (2882748) |
## Phase 2C complete (call-site lowering)
The DSL is end-to-end live. A user can declare a `#jni_class`, write
`inst.method(args)` inside a `#jni_env(env) { }` scope, and the
compiler synthesizes the full JNI dispatch — descriptor derivation,
slot interning, env passing, all of it.
## Phase 2D in progress (migration)
| # | What | Status |
|-------|---|---|
| 2D.1 | `library/modules/platform/android.sx` `sx_query_safe_insets_jni` migrated to declarative `#jni_class` blocks (first attempt). Four foreign-class declarations at android.sx top level, body uses `#jni_env(env) { ... }` with `inst.method(...)` calls. Verified macOS host + cross_compile but FAILED on chess build because `View` collided with `modules/ui/view.sx`'s `View` protocol — `imports.zig::addDecl` dropped android's `View` foreign_class on duplicate-name. | done (c9db2a8) |
| 2D.2 | Bare-name collision fixed via named-import sub-module pattern. The four `#jni_class` decls move into new `library/modules/platform/android_jni.sx`; android.sx imports them under `Jni :: #import "..."`. Receiver types use `*Jni.Activity` etc. Compiler-side: `scanDecls`/`lowerDecls` register foreign-class decls under both qualified (`Jni.Activity`) AND bare (`Activity`) names — qualified for receivers, bare for cross-class refs in method sigs. **On-device verified**: chess APK installed on Pixel, board renders with correct status-bar clearance, no crashes in logcat. Safe insets queried via the new declarative dispatch produce the same values as the pre-migration hand-rolled #jni_call chain. | done (60f3ffe) |
## Phase 2 functionally complete
Every JNI call site in `library/modules/platform/android.sx` now
flows through `#jni_class` + `#jni_env` + `inst.method(...)`.
Descriptor strings are gone from the dispatch chain — derivation
happens at lower time via `jni_descriptor.zig`. The hot-path
optimisation (lexical-direct env from 2.16b) keeps env
register-resident across the safe-insets chain. On-device chess
verified.
Remaining: `sx_android_install_input_handler` is the last entry in
`library/vendors/sx_android_jni/sx_android_jni.c`. It's not JNI
dispatch (it's struct-field plumbing on `ANativeActivityCallbacks`)
so the DSL doesn't apply. The file can stay until/unless we add
a separate plain-C ceremony reduction track.
## Surface: define-by-default + #foreign modifier + #jni_main (8d18160)
`Foo :: #jni_class("...") { ... }` now means "DEFINE a new Java class
at this path." `#foreign` flips it back to "reference an existing
class." `#jni_main` marks the launchable Activity. The model
generalises to `#objc_class`, `#swift_class`, etc.
```
Foo :: #foreign #jni_class("path") { ... } // reference (most chess usage)
Foo :: #jni_class("path") { ... } // define (runtime synth deferred)
Foo :: #jni_main #jni_class("path") { ... } // define + main Activity
```
Bodied methods inside a defined class are sx-side implementations;
`;`-terminated methods reference inherited / external impls. Foreign
classes stay `;`-only.
## `#jni_main` pipeline in progress
Driving the defined-class path end-to-end. The eventual user-facing
goal: a `#jni_main #jni_class("...") { ... }` decl replaces chess's
`android_main(app)` boilerplate with a declarative Activity. Split into
slices so each lands incrementally.
| # | What | Status |
|-------|---|---|
| jm.1 | Pure Java source emission in `src/ir/jni_java_emit.zig`. `emitJavaSource(allocator, fcd, opts) -> []u8` produces the `package ...; public class ... extends ... { @Override + private native sx_<m>(...); }` skeleton from a `ForeignClassDecl`. Six unit tests in `jni_java_emit.test.zig` lock the type matrix (void / primitives / `*void` → Object / cross-class refs via the supplied registry / `#extends` resolution / default package). | done (7ea7ad7) |
| jm.2 | AOT pipeline integration. `Compilation.lowering_jni_main_decls` is populated by `lowerToIR` (iterating `foreign_class_map` for `is_main && !is_foreign && runtime==jni_class`, deduped by `foreign_path`); each entry carries the pre-rendered Java source. `createApk` now (when the list is non-empty) writes `<stage>/java/<pkg>/<Class>.java`, invokes `javac --release 11 -classpath <android_jar>` to `<stage>/classes/`, invokes `d8 --release --lib <android_jar> --output <stage>` to produce `<stage>/classes.dex`, and zips the .dex into the unaligned APK at root. Manifest still hardcodes `android.app.NativeActivity` (slice jm.3) so the .dex is bundled but unreferenced at runtime. `javac` discovery: `$JAVA_HOME/bin/javac` → `which javac` → diagnostic. | done |
End-to-end verified by APK inspection: `dexdump -l plain` on the
sample APK shows `Lco/swipelab/sxjnimain/SxApp;` extending
`Landroid/app/NativeActivity;`. Non-`#jni_main` builds
(`99-android-egl-clear.sx`) produce the same APK shape as before
(no classes.dex, plain NativeActivity manifest).
Remaining slices for the pipeline:
- **jm.3** — manifest synthesis sets `<activity android:name="...">`
to the user's class + `android:hasCode="true"`.
- **jm.4** — lower emits a synthetic `JNI_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; Call<T>Method coverage spans bool / s8 / s16 /
u16 / s32 / s64 / f32 / f64 / pointer; varargs promotion is wired.
Phase 3 step 3.0 landed (for real this time): `inst.method(args)` on
an `#objc_class` / `#objc_protocol` receiver derives the selector via
default mangling (niladic → name verbatim; arity ≥ 1 → split on `_`,
each piece becomes a keyword with a trailing `:`) and lowers to
`objc_msg_send` against the cached SEL slot. Arity mismatches diagnose
at the call site with a remediation hint pointing at `#selector(...)`
override (3.2). New helpers `deriveObjcSelector` and
`lowerObjcMethodCall` at [lower.zig](../src/ir/lower.zig). Tests:
`examples/ffi-objc-dsl-{01-niladic,02-one-arg,03-multi-keyword,04-mismatch}.sx`
— landed previously as xfail-with-diagnostic, snapshots now flipped to
working output (and the mismatch case to the specific keyword-count
error).
Phase 3 step 3.1 landed: `Cls.static_method(args)` on an `#objc_class`
alias loads the class object through a module-scoped cached slot
(`OBJC_CLASSLIST_REFERENCES_<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.3** — `property name: Type` synthesizes
`inst.name` → `[inst name]` getter and `inst.name = x` →
`[inst setName: x]` setter. `#setter("...")` overrides the setter
selector.
- **Phase 3 step 3.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.7** — `impl ObjcProtoAlias for SxType` synthesizes
a runtime Obj-C class via `objc_allocateClassPair` /
`class_addMethod` / `class_addProtocol` / `objc_registerClassPair`.
Replaces the hand-written `uikit_register_classes` body in
`library/modules/platform/uikit.sx`.
- **Phase 3 step 3.8** — uikit.sx migration: retire every
`objc_getClass` lookup + hand-written class registration in favor
of the `#objc_class` / `impl Protocol for ...` surface that 3.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):
```sh
zig build && zig build test && bash tests/run_examples.sh && bash tests/cross_compile.sh
```
## Log
- 2026-05-19: Plan written, committed at `current/PLAN-FFI.md`.
- 2026-05-19: Steps 0.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](src/ir/lower.zig#L671)) drops the variadic
param from the IR signature and sets `Function.is_variadic`;
`emitFunctionDecl` ([src/ir/emit_llvm.zig:682](src/ir/emit_llvm.zig#L682))
passes `is_var_arg=1` to `LLVMFunctionType` accordingly. New
`promoteCVariadicArgs` applies C default argument promotion
(`bool/s8/s16/u8/u16 → s32`, `f32 → f64`) to extras past the fixed
param count. `packVariadicCallArgs` early-outs for foreign+variadic
so the slice-packing path is bypassed entirely. New test
`examples/ffi-foreign-cvariadic.sx` + `.c` exercise s64 / f64 / s32
returns through C `va_arg` over s32 / f64 / `*u8` element types.
Stale-snapshot drift from in-progress std.sx additions (`xml_escape`,
`path_join`, `BuildOptions.set_post_link_*`) re-pinned in 12
expected files — verified all diffs were dead-decl additions, string
slot renumbering, or the UB-driven `08-types` struct field (test
reads `u8 = ---` without setting it first). 150 host + 10 cross
tests green.
- 2026-05-21: Phase 3 step 3.0 — `inst.method(args)` DSL dispatch on
`#objc_class` / `#objc_protocol` receivers now lowers cleanly.
`lowerForeignMethodCall` branches on `fcd.runtime`: Obj-C runtimes
route through the new `lowerObjcMethodDispatch` helper, JNI
runtimes keep the existing path, Swift stays deferred. Default
selector mangling lives in `deriveObjcSelector`: niladic methods
use the sx-side name verbatim (`length` → `length`); arity-N
methods split on `_`, each piece becomes a keyword with a trailing
`:`, and the number of pieces must equal arity (excluding self).
Examples: `addObject(o)` → `addObject:`, `insertObject_atIndex
(o, i)` → `insertObject:atIndex:`. Mismatches diagnose at the
call site with `Obj-C method 'X': default selector mangling
expects N underscore-separated keyword(s) to match arity N (use
'#selector(\"...\")' to override)`. The override syntax itself is
slice 3.2. Reuses the existing `internObjcSelector` +
`objc_msg_send` IR opcodes, so the emit path is unchanged —
selectors get `@OBJC_SELECTOR_REFERENCES_<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 Call<T>Method 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 Call<T>Method 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 `#import`ed android.sx, the imported public global
shadowed chess's local decl for the unqualified name resolution,
so chess's writes (`g_viewport_w = fc.viewport_w`) silently
clobbered android.sx's s32 with the logical f32 cast to s32 (414
instead of 1080). `Gles3Gpu.pixel_w` then fed `glViewport(0,0,
414,831)`, clipping rendering to a 414-pixel box in the GL-
bottom-left. Refactor moved every piece of Android backend state
(app_window, EGL handles, viewport dims, render thread, frame
closure, touch ring + mutex, user_main_fn) onto AndroidPlatform
struct fields. All `sx_android_*` helpers now take
`plat: *AndroidPlatform` as their first arg; render thread entry
reads `plat` via `pthread_create`'s `arg`. Consumer (chess)
stashes the typed pointer in a `g_android_plat : *AndroidPlatform
= null` global declared inside its `inline if OS == .android`
import block, allocates + inits in `SxApp.onCreate` (BEFORE
`setContentView` triggers `surfaceCreated`), and main() on the
render thread reads it rather than re-allocating. Chess on Pixel
7 Pro now fills the screen end-to-end. Diagnostic was a one-shot
`__android_log_print` inside `Gles3Gpu.set_vertex_constants`
logging matrix elements + `self.pixel_w/h` — m0/m5 matched
logical 414/831 (projection correct) while pixel_w/h were also
414/831 (viewport wrong), pinning the bug to upstream of
`Gles3Gpu`. Instrumentation stripped after fix. 140 host + 9
cross tests green.
- 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`.