# 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.fu1–fu4 — 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.0–1.5 — `#objc_call` end-to-end for void return,
with selector interning matching clang's lowering shape.** Six small
commits:

| #   | Commit (oneline)                                                    |
|-----|---------------------------------------------------------------------|
| 1.0 | xfail parser test for `#objc_call(T)(recv, "sel:", args...)`        |
| 1.1 | parser + AST + sema + LSP recognize all three intrinsics            |
| 1.2 | xfail-then-green parser tests for `#jni_call` / `#jni_static_call`  |
| 1.3 | codegen for `#objc_call(void)(recv, "sel:")` — per-call lookup      |
| 1.4 | shared-selector regression test + IR-snapshot harness in `run_examples.sh` |
| 1.5 | selector interning — static `SEL*` slot per unique name, populated by `@llvm.global_ctors` constructor; hot path collapses to one load |

The IR-snapshot harness (`tests/expected/<name>.ir` alongside
`.txt`/`.exit`) lets us assert lowering shape without runtime
side-effects; the selector-sharing test was the first to use it
and pinned the 4→2 `sel_registerName` collapse.

`@OBJC_METH_VAR_NAME_` private string literals with
`unnamed_addr` + the `@llvm.global_ctors` constructor matches
clang's `@selector(...)` lowering byte-for-byte enough that the
system linker picks the right Mach-O sections on macOS/iOS.

**Phase 0 complete — 10 baseline FFI tests + cross-compile scaffold,
plus 2 codegen fixes surfaced along the way.**

| #    | Name                          | Notes                                                                                 |
|------|-------------------------------|---------------------------------------------------------------------------------------|
| 0.0  | tests/cross_compile.sh        | empty tuple list, exits 0; skip-with-warning when toolchains missing                  |
| 0.1  | ffi-01-primitives.sx          | every primitive type round-trips through `#import c { #source / #include }`           |
| 0.2  | ffi-02-small-struct.sx        | Vec2 (8 B), Vec4f (16 B HFA), Pair64 (2×s64), Quad32 (4×s32) — four ABI slots         |
| 0.3  | ffi-03-large-struct.sx        | Big24 (24 B), Big48 (48 B) via byval params + sret return                             |
| 0.4  | ffi-04-fp-struct.sx           | FQuad (16 B HFA), DQuad (32 B HFA — UIEdgeInsets-shape)                               |
| 0.5  | ffi-05-string-args.sx         | [:0]u8, sx `string` slice-decay, [*]u8 + len, mutate-via-C, C-returned pointer        |
| 0.6  | ffi-06-callback.sx            | sx fn -> C fn-pointer; single-arg + (ctx-ptr, value) forms; side effects via globals  |
| 0.7  | ffi-07-c-import-block.sx      | `#import c { #include / #source }` resolves via stdlib-path search (library/vendors/) |
| 0.8  | ffi-08-foreign-in-method.sx   | `#foreign` from struct method / protocol impl / closure / `inline if OS == { case }`  |
| 0.9  | ffi-09-foreign-result-chain.sx| Opaque handle: chain, struct field, `List(*void)` iteration                           |
| 0.10 | 94-foreign-global.sx          | Extended with cross-file companion; both files declare the same `#foreign` global     |

### Codegen fixes landed in Phase 0

- **issue-0036 / promoted to `101-ffi-medium-struct.sx`**. `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.0–M1.3 + M2.1–M2.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.0–4.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.0–A.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.6–1.14)

**`#objc_call` end-to-end across every return shape + enclosing
construct.** Nine commits:

| #     | What                                                                                  |
|-------|---------------------------------------------------------------------------------------|
| 1.6   | `objc_msg_send` IR opcode + per-call-site LLVM function type via opaque pointers      |
| 1.7   | Small struct returns: 16 B HFA (NSPoint), 16 B int (NSRange), 32 B HFA (NSRect)       |
| 1.8   | sret transform for >16 B non-HFA returns; body-side `ret` rewrite for sx-defined IMPs |
| 1.9   | UIEdgeInsets-shape 4×f64 HFA round-trip                                                |
| 1.10  | Multi-keyword selectors (`combine:and:`) — name mangling matches clang                |
| 1.11–13 | `#objc_call` inside struct method / protocol impl / closure body / generic fn       |
| 1.14  | OS-gated `inline if OS == { case }` cross-compiles cleanly to Android                 |

Real Obj-C ABI verified via round-trip through `class_addMethod`-
registered IMPs:
- Triple {11, 22, 33} sret round-trip (1.8)
- UIEdgeInsets {1.5, 2.5, 3.5, 4.5} HFA round-trip (1.9)
- `combine(7, 42)` → 742 multi-arg (1.10)

109 host tests + 1 cross-compile target pass. Chess Android +
iOS-sim builds still clean. Two findings filed:

- ~~**issue-0038**~~: closure free-variable analyzer skipped
  `FfiIntrinsicCall` nodes. Fixed in df2ccf7; promoted to
  `examples/103-ffi-closure-capture.sx`.

## Phase 1D in progress (uikit.sx migration)

User chose Phase 1D before Phase 1C — consume `#objc_call` in the
real call sites first to flush out any cluster-shape issues before
landing the parallel JNI codegen.

| #    | Cluster                          | Status |
|------|----------------------------------|--------|
| 1.25 | `safeAreaInsets` (UIEdgeInsets HFA, in `uikit_refresh_safe_insets`) — also drops a dead `sel_safe_insets` decl in `uikit_scene_will_connect_ios` | done (bcbf2ac) |
| 1.26 | `uikit_chdir_to_bundle` — `NSBundle.mainBundle.resourcePath` chain (2× `*void` returns through one `msg_o` cast) | done (3518d0e) |
| 1.27 | `uikit_read_screen_scale` — `UIScreen.mainScreen.nativeScale` (class-method `*void` + instance-method `f64`); first standalone `#objc_call(f64)` exercise | done (4844f57) |
| 1.28 | `show_keyboard` / `hide_keyboard` pair — `becomeFirstResponder` / `resignFirstResponder` (BOOL returns, discarded). Initially landed as `#objc_call(u8)`; corrected to `#objc_call(bool)` in follow-up ee53348. Runtime-verified by the locked-in test `examples/ffi-objc-call-11-bool-return.sx` (e52f9f2) — two BOOL-returning IMPs via `class_addMethod`. | done |
| 1.29 | `uikit_create_gl_context` — `alloc` / `initWithAPI:` / `setCurrentContext:` + duplicate of 1.27's screen-scale read | done |
| 1.30 | `uikit_subscribe_keyboard_notifications` — first standalone 4-keyword selector exercise (`addObserver:selector:name:object:`) | done |
| 1.31 | `uikit_scene_will_connect_ios` — biggest cluster; the iOS scene-lifecycle entry. UIWindow / UIViewController / SxGLView wiring; EAGL drawable-properties dict build; `nativeScale` + `setContentScaleFactor:` DPI path; `displayLinkWithTarget:selector:` + run-loop install. Exercises every return shape used in uikit.sx. Net -44 lines (104 → 60). | done (b3558c3) |
| 1.32 | `uikit_keyboard_will_change_frame` — `userInfo` / `objectForKey:` / `CGRectValue` / `doubleValue` / `unsignedLongValue` / `screen.bounds`. First standalone exercise of `#objc_call(CGRect)` (HFA, structurally equivalent to UIEdgeInsets) and `#objc_call(u64)` (LLVM-equivalent to s64). Net -14 lines. Runtime-verified by the locked-in test `examples/ffi-objc-call-12-rect-u64-returns.sx` (ac78490). | done (e1d300c) |
| 1.33 | **uikit.sx sweep — all remaining dispatch sites.** `renderbufferStorage:fromDrawable:` (bool, GL setup); `presentRenderbuffer:` (bool, every frame); `targetTimestamp` / `duration` (f64, every frame in `uikit_gl_view_tick`); `bounds` (CGRect, `uikit_compute_layer_pixel_size`); `locationInView:` (CGPoint HFA, every touch); `anyObject` (*void, every touch). First standalone `#objc_call(CGPoint)` exercise. Net -15 lines. Runtime-verified end-to-end: tapped a black pawn in iOS-sim chess and the move played correctly (1...d5, 2...d4). | done |

Verification per cluster: zig build / zig test / run_examples /
cross_compile all green; chess `sx build --target ios-sim main.sx`
and `--target android main.sx` both compile clean.

## Phase 1C in progress (`#jni_call` codegen)

Phase 1D for uikit.sx is done (`-98` lines, zero typed-cast dispatch
sites). Phase 1C is now active — the JNI parallel to 1.3–1.10. The
parser already accepts the syntax (step 1.2 / commit landed earlier);
the work that remains is lowering + emit_llvm.

| #    | What                                                                                  | Status |
|------|---------------------------------------------------------------------------------------|--------|
| 1.15 | `#jni_call(void)` codegen — new `.jni_msg_send` IR opcode + emit_llvm expansion: load `*env` for the vtable, GEP into slots 31 (GetObjectClass), 33 (GetMethodID), 61 (CallVoidMethod). No method-ID caching yet; static dispatch + non-void returns drop to `LLVMGetUndef` until 1.18+. | done (134c197 xfail + 9afcaa5 fix) |
| 1.16 | Lock in pre-caching IR shape — two `#jni_call` sites with literal `("noop", "()V")` emit two independent `GetMethodID` calls. IR snapshot at `tests/expected/ffi-jni-call-03-methodid-sharing.ir`. | done (13018ef) |
| 1.17 | Literal-keyed slot interning — `JniMsgSend.cache_key: ?CacheKey` carries the literal `(name, sig)` pair from `lower.zig`; `emit_llvm.getOrCreateJniSlots` interns `@SX_JNI_CLS_<key>` and `@SX_JNI_MID_<key>` globals per unique pair; per-call lowering does null-check + lazy populate via `GetObjectClass` → `NewGlobalRef` (slot 21) → `GetMethodID` on miss. Two literal sites now share one slot pair. | done (0d883b4) |
| 1.18 | `#jni_call(s32)` → CallIntMethod (vtable slot 49). One arm added to the `call_method_offset` switch; reuses the 1.17 cache. | done (1d7ea72 xfail + ebcfe4c fix) |
| 1.18+ | Lift JNI vtable offsets into a `const Jni` named-constants struct. Pre-loaded Object/Boolean/Long/Float/Double slots so 1.19–1.22 are one-line switch arms. | done (c1877fc) |
| 1.19 | `#jni_call(s64)` → CallLongMethod (vtable slot 52). One arm added. | done (da5b635 xfail + 5945a8c fix) |
| 1.20 | `#jni_call(f64)` → CallDoubleMethod (vtable slot 58). First non-integer JNI return. | done (xfail + ca4ba75 fix) |
| 1.21 | `#jni_call(bool)` → CallBooleanMethod (vtable slot 37). | done (xfail + b0e8659 fix) |
| 1.22 | `#jni_call(*void)` → CallObjectMethod (vtable slot 34). Pointer-return detected via `TypeInfo.pointer | .many_pointer` ahead of the primitive switch. LocalRef cleanup deferred — chess consumes objects inline. | 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.15–1.24) shipped. `#jni_call(T)` and
`#jni_static_call(T)` lower to JNI vtable indirection with shared
`(name, sig)` literal-keyed slot interning (one `jclass GlobalRef` +
one `jmethodID` per unique pair, populated lazily on the first
matching call). Return-type matrix covers `void` / `s32` / `s64` /
`f64` / `bool` / `*T`. Static dispatch skips `GetObjectClass` and
uses the parallel `GetStaticMethodID` + `CallStatic<Type>Method`
family. Both OS gates verified by `cross_compile.sh` (3/3 tuples
green).

## Phase 1D for `sx_android_jni.c` in progress

| #    | Cluster                                                                              | Status |
|------|--------------------------------------------------------------------------------------|--------|
| 1.25 | sx-side `sx_query_safe_insets_jni` in `android.sx` — reimplements the JNI dispatch chain (`getWindow → getDecorView → getRootWindowInsets → getSystemWindowInset{Top,Left,Bottom,Right}`) via `#jni_call`. Takes a pre-attached `env: *void` so the JavaVM plumbing stays in C. | done (ba0a1a1) |
| 1.26 | JavaVM vtable dispatch hand-rolled in sx — `sx_load_ptr_at`, `sx_load_javavm_fn`, `sx_android_get_env(activity, out_attached)`, `sx_android_detach_env`, `sx_android_activity_clazz`. Slot indices: GetEnv=6, AttachCurrentThread=4, DetachCurrentThread=5. ANativeActivity offsets: vm=8, clazz=24 (64-bit). | done (885b423) |
| 1.27 | `AndroidPlatform.safe_insets` switched from `sx_android_query_safe_insets` (C foreign) to the sx pipeline: `get_env → activity_clazz → sx_query_safe_insets_jni → detach_env`. Seven `(SX_JNI_CLS, SX_JNI_MID)` slot pairs visible in chess Android IR. | done (6e65324) |
| 1.28 | On-device chess regression — APK built with `sx build --target android --apk ...`, installed via `adb install -r`, launched on Pixel device. Screencap confirms board renders with correct top inset (status bar clearance), all pieces in starting positions. Validates the full sx-side JNI stack: JavaVM env attach + 7-step dispatch chain + slot interning. | done |
| 1.29 | Retired the C `sx_android_query_safe_insets` body (and its `#foreign` decl) — all dispatch now goes through sx + `#jni_call`. `<android/native_activity.h>` and `<jni.h>` includes also removed. `sx_android_install_input_handler` stays. Net -55 lines in .c, on-device chess regression re-verified. | done (4ddee93) |

## Phase 1D for sx_android_jni.c complete

All five sub-steps (1.25–1.29) shipped. The safe-insets JNI chain
has fully moved from C to sx:

- C lines retired: ~55 (the `sx_android_query_safe_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.1–R.5 retiring the legacy NativeActivity surface — all
landed; chess on Pixel runs end-to-end as the integration witness).
JNI return + parameter type validation lives in lowering with source-
spanned diagnostics; 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.4–3.6** — `#extends`, foreign type aliases (`id` /
  `Class` / `SEL` / `BOOL` / `instancetype` / `_Nullable T`),
  `static new :: (args...) -> *Self;` synthesizing `[[Class alloc]
  init...]` chains.
- **Phase 3 step 3.7** — `impl ObjcProtoAlias for SxType` synthesizes
  a runtime Obj-C class via `objc_allocateClassPair` /
  `class_addMethod` / `class_addProtocol` / `objc_registerClassPair`.
  Replaces the hand-written `uikit_register_classes` body in
  `library/modules/platform/uikit.sx`.
- **Phase 3 step 3.8** — uikit.sx migration: retire every
  `objc_getClass` lookup + hand-written class registration in favor
  of the `#objc_class` / `impl Protocol for ...` surface that 3.0–3.7
  ship.

After Phase 3:
- **`#jni_main` slice jm.5** — stdlib base class
  `library/modules/runtime/jni/native_activity.sx` so consumers
  override individual lifecycle methods on a stdlib-provided
  Activity instead of writing the AndroidPlatform plumbing from
  scratch. Concrete payoff: chess's SxApp shrinks ~70 lines.
- **Phase 4** — Swift bridge.
- **Phase 5** — `#import jni auto { classpath ... }` synthesizes
  `#jni_class` decls from .jar bytecode.

Cadence-rule reminder (each commit either locks in current behavior
with a passing test OR turns an xfail green — never both in one
commit):

```sh
zig build && zig build test && bash tests/run_examples.sh && bash tests/cross_compile.sh
```

## Log

- 2026-05-19: Plan written, committed at `current/PLAN-FFI.md`.
- 2026-05-19: Steps 0.0–0.2 done (primitives, small-struct baselines).
- 2026-05-19: issue-0036 fixed via emit_llvm coerceArg struct↔array bridges.
- 2026-05-19: Steps 0.2 (folded back) – 0.3 done. >16 B sret return
  transform added to emit_llvm.zig.
- 2026-05-19: Steps 0.4–0.6 done (FP-aggregate, strings, callbacks).
- 2026-05-19: Step 0.7 done; imports.zig stale-ci fix landed alongside.
- 2026-05-19: Test C helpers reorg — `examples/ffi-NN-*.{c,h}` next to
  the `.sx`. `vendors/ffi_*/` removed.
- 2026-05-19: Steps 0.8, 0.9 done (constructs-around-FFI, handle chains).
- 2026-05-19: Step 0.10 done; issue-0037 (`@foreign_global` from helper
  fn → undef) filed.
- **Phase 0 complete. 97/97 regression tests pass. Chess Android +
  iOS-sim both build clean.**
- 2026-05-19: Phase 1.0–1.5 done. `#objc_call(void)` works end-to-end
  with clang-shape selector interning. 101/101 regression tests pass;
  IR-snapshot harness added; `tests/expected/<name>.ir` snapshots
  catch lowering changes invisible in runtime output.
- 2026-05-19: Phase 1.6–1.14 done (all return shapes + enclosing
  constructs). 109 host + 1 cross-compile target green.
- 2026-05-19: issue-0037 fixed (ptr↔int in `coerceToType` + `bitcast`);
  test promoted to `examples/102-foreign-global-from-helper.sx`.
  issue-0038 (closure free-var analysis skips `FfiIntrinsicCall`)
  still open.
- 2026-05-19: Phase 1D cluster 1.25 done — `uikit_refresh_safe_insets`
  migrated to `#objc_call(UIEdgeInsets)(plat.gl_view, "safeAreaInsets")`;
  dead `sel_safe_insets` decl dropped from `uikit_scene_will_connect_ios`.
  Net -3 lines. Chess iOS-sim + Android still compile clean. Committed
  as bcbf2ac. iOS-sim chess: board renders with correct status-bar
  clearance.
- 2026-05-19: Phase 1D cluster 1.26 done — `uikit_chdir_to_bundle`
  migrated to two `#objc_call(*void)` calls (`mainBundle` class method
  + `resourcePath` instance method). Net -3 lines. iOS-sim chess: app
  loads with all piece assets rendered (proves `chdir` to the bundle
  resource path still succeeds).
- 2026-05-19: Phase 1D cluster 1.27 done — `uikit_read_screen_scale`
  via `#objc_call(*void)` + `#objc_call(f64)`. First standalone
  `#objc_call(f64)` exercise; previously only covered indirectly by
  the UIEdgeInsets 4×f64 HFA test. Net -4 lines. iOS-sim chess: input
  hit-testing + sharp rendering confirms `dpi_scale` is correct.
- 2026-05-19: Phase 1D clusters 1.28–1.30 done in one batch commit
  (65643fb). `show_keyboard` / `hide_keyboard` (u8 returns,
  compile-only — chess startup doesn't reach them);
  `uikit_create_gl_context` (alloc, initWithAPI:, setCurrentContext:
  + the screen-scale dup from 1.27);
  `uikit_subscribe_keyboard_notifications` (first standalone 4-keyword
  selector). Net -15 lines on this commit. uikit.sx now 912 lines
  (started at 937 → -25 cumulative across Phase 1D so far).
  iOS-sim chess launches cleanly.
- 2026-05-19: 1.28 follow-up (ee53348) — `#objc_call(u8)` →
  `#objc_call(bool)` on the keyboard pair, matching Apple's documented
  BOOL return type.
- 2026-05-19: 1.28 backfill (e52f9f2) — wrote
  `examples/ffi-objc-call-11-bool-return.sx` to lock in
  `#objc_call(bool)` against two `class_addMethod`-registered IMPs.
  110/110 host tests pass.
- 2026-05-19: Phase 1D cluster 1.31 done — the big one,
  `uikit_scene_will_connect_ios`. Touches every return shape used in
  uikit.sx in one launch path. Net -44 lines on this commit; also
  dropped a stale `EAGLContext := objc_getClass(...)` decl that wasn't
  used in this function. uikit.sx now 868 lines (started at 937 →
  -69 cumulative across Phase 1D). iOS-sim chess launches cleanly
  through the whole migrated path: window/VC/GL view wiring, EAGL
  drawable dict, DPI scaling, display link install.
- 2026-05-19: Phase 1D cluster 1.32 done —
  `uikit_keyboard_will_change_frame` (the keyboard notification
  callback). First standalone `#objc_call(CGRect)` and
  `#objc_call(u64)` exercises. Net -14 lines. Compile + launch clean;
  function body isn't reached by chess startup so runtime exercise is
  transitive only.
- 2026-05-19: Phase 1D cluster 1.33 done — sweep of all remaining
  dispatch sites in uikit.sx: `renderbufferStorage:fromDrawable:`,
  `presentRenderbuffer:`, `targetTimestamp`/`duration` per-frame reads,
  layer `bounds`, touch `locationInView:` (first `#objc_call(CGPoint)`
  exercise), `anyObject`. Net -15 lines. Runtime-verified end-to-end:
  tapped a pawn in iOS-sim chess and the move played correctly.
- **Phase 1D for uikit.sx complete.** Zero `xx objc_msgSend` typed
  casts remain. uikit.sx 839 lines (937 → -98).
- 2026-05-19: **Phase 1C started.** Step 1.15 done (134c197 xfail +
  9afcaa5 fix). New `.jni_msg_send` IR opcode + emit_llvm expansion
  for `#jni_call(void)` instance dispatch. Vtable indirection: load
  `*env`, GEP into slots 31 (GetObjectClass) / 33 (GetMethodID) / 61
  (CallVoidMethod), call each. String slices auto-extracted to raw
  `ptr` via new `extractSlicePtr` helper. Static dispatch + non-void
  returns drop to `LLVMGetUndef` (next steps wire them). Android
  cross-compile passes for `examples/ffi-jni-call-02-void.sx`. Host
  112/112 + cross 2/2 + chess both targets clean.
- 2026-05-19: Phase 1C step 1.16 done (13018ef). Added
  `examples/ffi-jni-call-03-methodid-sharing.sx` with two
  `#jni_call` sites against literal `("noop", "()V")`; IR snapshot
  locks in today's two-GetMethodID-call shape. Runtime is a no-op —
  `unused_jni` reachable through a runtime-readable `g_should_call`
  global so the function body survives constant-fold but the
  dereferences never execute.
- 2026-05-19: Phase 1C step 1.17 done (0d883b4). Literal-keyed slot
  interning: `JniMsgSend.cache_key` carries the literal `(name,
  sig)` from lower.zig; emit_llvm interns `@SX_JNI_CLS_<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`.
