Apple documents `-becomeFirstResponder` and `-resignFirstResponder` as
returning `BOOL`. The pre-`#objc_call` cast pattern in this file used
`u8` because BOOL is ABI-equivalent to a 1-byte unsigned integer on
both i386 (signed char) and arm64 (`bool`). The initial 1.28
migration carried that `u8` typing forward without question; switching
to `bool` matches the documented API and aligns with the BOOL→bool
mapping called out in PLAN-FFI.md Phase 3.
First standalone exercise of `#objc_call(bool)`. The lowering is
identical to `#objc_call(u8)` at the ABI layer (single byte in `w0`
on AAPCS64), but the source-level type is now meaningful.
Three Phase 1D clusters in one commit (user opted for less iOS-sim
verification between each).
1.28 — `show_keyboard` / `hide_keyboard` use `#objc_call(u8)` against
`becomeFirstResponder` / `resignFirstResponder`. Compile-only; chess
startup doesn't reach the keyboard path, so the runtime side of this
cluster is a verification gap to backfill at the end of Phase 1D.
1.29 — `uikit_create_gl_context` migrates `alloc` / `initWithAPI:` /
`setCurrentContext:` and folds in the same `mainScreen.nativeScale`
read shape already migrated in 1.27. EAGL context creation runs on
launch, so this cluster IS runtime-exercised.
1.30 — `uikit_subscribe_keyboard_notifications` migrates the
`defaultCenter` + `addObserver:selector:name:object:` pair. First
standalone exercise of a 4-keyword selector through `#objc_call`.
Notification-center wiring runs at launch, so runtime-exercised.
Net -23 lines across the file.
iOS-sim chess regression smoke: app launches cleanly into a fresh
board state. Status-bar clearance, sharp rendering, and asset loading
all good — confirming clusters 1.25–1.27 still work alongside the new
ones.
Third Phase 1D cluster. `UIScreen.mainScreen.nativeScale` chain reads
through `#objc_call(*void)` + `#objc_call(f64)`. First standalone
`#objc_call(f64)` exercise — `f64` returns had only been covered
indirectly by the 4×f64 UIEdgeInsets HFA path. Net -4 lines.
iOS-sim chess regression smoke: sharp text rendering + accurate touch
hit-testing both confirm `plat.dpi_scale` is being populated correctly
through the new path.
Second Phase 1D cluster. NSBundle.mainBundle.resourcePath chain now
dispatches through `#objc_call(*void)` instead of a shared `msg_o`
typed cast — covers both class-method (`+mainBundle`) and
instance-method (`-resourcePath`) shapes through one intrinsic. Net
-3 lines.
iOS-sim chess regression smoke: app launches with all piece assets
rendered, which is the visible signal that `chdir` to the bundle's
resource path still succeeds.
First Phase 1D migration cluster. `uikit_refresh_safe_insets` reads
`safeAreaInsets` through `#objc_call(UIEdgeInsets)` instead of the
hand-typed `objc_msgSend` cast + `sel_registerName` triple, and a dead
`sel_safe_insets` selector decl in `uikit_scene_will_connect_ios` goes
away with it. Net -3 lines.
iOS-sim chess regression smoke: SxChess launches, board renders with
correct status-bar clearance — `safe_top` is populated correctly,
which is the actual ABI under test (32 B HFA returned in v0..v3).
109/109 regression tests pass; chess Android + iOS-sim still
build clean.
Root cause: sx's `xx <ptr>` cast targeting an integer type
(common pattern: `xx u64 = xx @some_global`) lowered to a no-op
because `coerceToType` had branches for int↔float and same-kind
widen/narrow, but nothing for pointer↔integer. The cast left the
value as a pointer Ref, and `emitInst`'s `.ret` arm tried to
coerce a `ptr` value to an `i64` slot — coerceArg had no
ptr↔int branch either, fell through to undef.
Why it worked in main but failed in helpers: an
`alloca u64`+`store ptr @g, alloca`+`load i64, alloca` sequence
preserves the address bits as raw memory, so the
"store-then-load through an alloca" workaround happened to do
the right thing without a real cast. A `ret i64 <ptr>` has no
such intermediate slot and triggers an LLVM type mismatch.
Fix layered into two existing IR opcodes:
lower.zig (coerceToType):
new branch — when src and dst types are ptr↔int, emit a
`bitcast` IR opcode with the right from/to. Mirrors how
int↔float emits `.int_to_float` / `.float_to_int`.
emit_llvm.zig (.bitcast arm):
dispatch ptr→int to `LLVMBuildPtrToInt` (+ trunc/zext if the
target int width != 64), int→ptr to `LLVMBuildIntToPtr`. The
"real bitcast" path stays for same-kind type punning.
Modern LLVM's BuildBitCast rejects ptr↔int directly, hence
the dispatch.
The fix also closes a quiet behavior gap that affected non-`#foreign`
globals (any `xx @<global>` from a helper fn). Surfaced while
investigating issue-0037; verified independently with a
non-`#foreign` sx-side global of type `s64`.
File mechanics: issue-0037 promoted to a focused feature example
per CLAUDE.md's resolution flow:
examples/issue-0037.sx -> examples/102-foreign-global-from-helper.sx
tests/expected/issue-0037.{txt,exit} -> tests/expected/102-foreign-global-from-helper.{txt,exit}
ffi-objc-call-03 + ffi-objc-call-06 IR snapshots updated to
reflect the ptr→int store-via-ptrtoint shape that's now correct
at the LLVM-IR level (same bits in memory, but properly typed).
109/109 host tests pass; tests/cross_compile.sh's first real tuple
(`android | examples/ffi-objc-call-10-os-gate.sx`) compiles
through `sx build --target android` without finding any
`@objc_msgSend` / `@sel_registerName` symbols in the output —
the `inline if OS == .ios { #objc_call(...) }` arm is stripped
at sx compile time before emit_llvm runs, so the Android
toolchain (Bionic + libGLESv3 / NDK linker) doesn't see the
Obj-C runtime references that would otherwise be undefined.
Host (macOS): the example prints "host stripped both" — the iOS
arm is stripped (we're not iOS) AND the Android arm is stripped
(we're not Android), confirming `inline if OS == { case }`
symmetric strip-and-render works around `#objc_call` sites.
The example carries a 3-line `android_main` trampoline so the
NDK linker's `-u ANativeActivity_onCreate` / entry-point
discovery is satisfied — pattern shared with chess + the other
android examples.
108/108 regression tests pass (+ffi-objc-call-09-in-construct,
+issue-0038 from the prior commit).
One trivial Obj-C call (`[obj hash]` returning NSUInteger) routed
through four sx surface constructs:
1. struct method body Probe.fetch
2. protocol impl method body impl Hashable for Probe
3. closure value body make_hasher
4. generic function body hash_through(recv: $T)
No new ABI shapes touched — pins that the `objc_msg_send` lowering
emits identical call shapes regardless of enclosing scope. Each
case validates the result `h_N == h_1` after threading `recv`
appropriately for each context.
The closure path reaches `recv` via a module-level global rather
than capturing the surrounding parameter — issue-0038 (prior
commit) documents the closure free-variable analyzer missing the
`FfiIntrinsicCall` node, with a clean workaround pinned.
Surfaced while writing the Phase 1.11 in-construct test. The
closure free-variable analyzer doesn't recursively visit the
`ffi_intrinsic_call` AST node introduced in Phase 1.1, so any
identifier used inside `#objc_call` / `#jni_call` /
`#jni_static_call` from a closure body trips:
error: unresolved: '<name>'
The same identifier captured from the same scope into a plain
expression resolves fine — so the bug is localized to whatever
recursive arm-walk powers the capture analysis.
Likely fix: add an `ffi_intrinsic_call => { ... }` arm wherever
the `.call =>` arm visits `callee` + `args`. Candidate files:
- src/sema.zig (capture / scope tracking)
- src/ir/lower.zig (closure body lowering / `lowerLambda`)
Both should be checked.
Workaround in the meantime: reach the captured value via a
module-level global from inside the closure body. See the
`g_hasher_recv` pattern in
examples/ffi-objc-call-09-in-construct.sx for an applied
instance.
106/106 regression tests pass (+ffi-objc-call-08-multi-keyword).
`#objc_call(s32)(instance, "combine:and:", 7, 42)` round-trips
end-to-end via class_addMethod-registered IMP that does
`a * 100 + b` → 742. Pins three things:
1. The two-keyword selector "combine:and:" parses, mangles, and
interns under the symbol `@OBJC_SELECTOR_REFERENCES_combine_and_`
(every `:` → `_` — matches clang).
2. Multi-arg call lowering correctly puts arg0 / arg1 in the right
slots after recv / sel.
3. The IMP-side sx fn signature `(self, _cmd, a: s32, b: s32)`
with `callconv(.c)` interops with the Obj-C runtime's typical
IMP shape, and the runtime forwards the keyword args to the
right physical positions.
No codegen change — Phase 1.6's variadic-args branch in the
`objc_msg_send` lowering already handled this; this test just
locks in the surface.
105/105 regression tests pass (+ffi-objc-call-07-fp-hfa-return).
Same round-trip pattern as 1.8 — register an Obj-C class at
runtime with class_addMethod, IMP returns specific non-zero values,
#objc_call reads them back — but for an all-double 32 B HFA
instead of a 24 B int aggregate.
Locks in the f32-vs-f64 landmine that bit us when we first
wrote safeAreaInsets in uikit.sx: the homogeneous-float-aggregate
ABI routes 1..4 f32 or f64 fields through v0..v3 (AAPCS64) /
xmm0..xmm3 (SysV AMD64) WITHOUT integer coercion. As long as the
LLVM call-site function type carries the precise struct (which
our `objc_msg_send` arm does), the backend lowers it correctly.
This is the smaller cousin of 1.8 — 1.8 needed an emit_llvm code
change to make the sret transform work; 1.9 needs no codegen
change because HFAs of any size up to v0..v3 stay register-resident.
The test just pins that path with a real, value-bearing IMP so a
future ABI-rule shake-up has a regression net.
104/104 regression tests pass. The Triple round-trip
(triple_imp writes {11, 22, 33} on the IMP side → #objc_call(Triple)
reads them back) is the test of record.
emit_llvm.zig changes:
1. `objc_msg_send` arm — when `needsByval(ret_ty)` (same predicate
the plain-foreign-call path uses), apply the sret transform:
- ret type collapses to void
- prepend a `ptr` param at index 0 (call site provides an
alloca slot)
- mirror `sret(<RetType>)` on the call site so the AArch64 x8
/ SysV-AMD64 hidden-ptr ABI lowers correctly
- load the result from the slot post-call
The IR shape now matches clang exactly:
call void @objc_msgSend(ptr sret({...}) %slot, ptr %recv, ptr %sel)
2. `.ret` arm — the body-side counterpart for sx fns whose declared
return type is sret-shaped (sx-defined IMPs registered via
`class_addMethod` produce these). When the current function's
`needsByval(func.ret)` predicate holds, store the IR ret value
through the prepended sret slot (param 0) and emit `ret void`.
Previously the unconditional coerceArg path turned the struct
value into `undef` and emitted `ret void undef` — illegal LLVM.
Test mechanics: registers `SxTripleProbe : NSObject` at runtime via
`objc_allocateClassPair` + `class_addMethod`, IMP returns
Triple{11, 22, 33}. `#objc_call(Triple)(instance, "tripleValue")`
gets them back, round-trip pinned in the .txt snapshot and the
IR-shape snapshot.
103/103 regression tests pass (+ffi-objc-call-06-sret-return).
The runtime output is misleadingly clean — `[nil tripleValue]`
zeros all three fields because libobjc's nil-stub clears the
return registers. But the IR snapshot reveals the actual ABI
mismatch:
%objc.msg = call { i64, i64, i64 } @objc_msgSend(ptr null, ptr %load)
A live receiver returning a non-zero `Triple` would surface
garbage in the third field — the AArch64 backend lowers
{ i64, i64, i64 } returns to x0/x1 pair + a third register that
the runtime's sret-shaped stub doesn't populate.
Next commit (1.8b): emit_llvm's `objc_msg_send` arm gains the
same sret transform we did for plain `#foreign` calls in Phase
0.3 — ret type collapses to void, prepend a ptr sret param,
alloca the result slot at the call site, mirror the
`sret(<T>)` attribute on the call, load result from the slot
post-call. IR snapshot will flip to:
%slot = alloca <Triple>
call void @objc_msgSend(ptr sret(<Triple>) %slot, ptr null, ptr %load)
%objc.msg = load <Triple>, ptr %slot
103/103 regression tests pass (+ffi-objc-call-05-struct-returns).
Three return shapes all round-trip cleanly with the existing Phase
1.6 `objc_msg_send` lowering — no codegen change needed because
emit_llvm.zig hands the IR struct type straight to LLVMBuildCall2
and the AArch64 / SysV AMD64 backends already know how to lower:
NSPoint — 16 B HFA (2×f64) → v0, v1 (AAPCS64) / xmm0, xmm1 (SysV)
NSRange — 16 B 2×u64 → x0, x1 register pair via [2 x i64]
NSRect — 32 B HFA (4×f64) → v0..v3 (AAPCS64) / xmm0..xmm3 (SysV)
Verified against the Obj-C runtime's `[nil structMethod]`-returns-
zero contract — no real-object setup needed, but the wider ABI
path runs exactly as it would for live receivers (the registers
the runtime stub uses come back through the same lowering).
>16 B non-HFA aggregates (e.g. {3×s64}) trip a sret cliff and
land in Phase 1.8. Verified locally that they return garbage in
the trailing field today — register pair / quad won't carry the
extra storage, and emit_llvm's `objc_msg_send` arm doesn't apply
the sret transform yet.
102/102 regression tests pass; chess Android + iOS-sim still build
clean. `ffi-objc-call-04-primitive-returns` flips from xfail to
passing with both nil-recv and real-recv flavors of *void / s64
returns exercised.
Key change: a new `objc_msg_send` IR opcode bundles (recv, sel,
extra args) and carries the return type via the `Inst.ty` field.
emit_llvm.zig builds a per-call-site LLVM function type from the
argument Refs' IR types (recv/sel as ptr; extra args through
abiCoerceParamType) and dispatches with LLVMBuildCall2. One
declared `@objc_msgSend` symbol is reused across every return
type — opaque pointers make the function value type-erased, so
each call site picks its own ABI.
before: one (recv, sel) -> ptr LLVM declaration, hard-coded
per call site; only void return wired in 1.3.
after: same declaration, each call site provides a fresh
LLVMBuildCall2 fn-type → s64 / *void / bool / f64
returns all dispatch correctly without separate FuncIds.
Selector init mechanism: stayed with the @llvm.global_ctors
constructor. Investigated clang's
`__DATA,__objc_selrefs` + `externally_initialized` shape — works
for fully-linked binaries (dyld substitutes the SEL at load
time) but **LLVM ORC JIT** (the engine behind `sx run`) doesn't
process Mach-O Obj-C metadata sections, so the slot keeps its
initial value (the method-name string pointer) and dispatch
crashes with "<null selector>". The portable choice: keep the
constructor AND inject a direct call to it at `main`'s entry —
idempotent under dyld (sel_registerName returns the same SEL on
re-registration), required for ORC JIT.
Files touched:
src/ir/inst.zig | new ObjcMsgSend struct + opcode
src/ir/lower.zig | drop the void-only restriction; emit the
new opcode; remove the orphaned
getObjcMsgSendFid path (objc_msgSend
declaration moved to emit_llvm)
src/ir/emit_llvm.zig | objc_msg_send arm (per-call-site
LLVMBuildCall2); lazy `@objc_msgSend`
declaration via getObjcMsgSendValue;
emitObjcSelectorInit refactored to inject
the ctor call at main's entry
src/ir/{print,interp}.zig | switch arms for the new opcode
`ffi-objc-call-03-selector-sharing.ir` snapshot updates to
reflect the new shape (the `call ... @objc_msgSend` call sites
no longer mention a typed wrapper).
102/102 regression tests pass (+ffi-objc-call-04-primitive-returns
with xfail snapshot capturing today's diagnostic).
Pinned scenario: `[NSObject class]` — `#objc_call(*void)(null, "class")`.
Should return a non-null Class pointer once the lowering supports
non-void returns. Today the Phase 1.3 restriction trips with:
#objc_call: only `void` return + (recv, selector) is lowered today;
non-void / arg-bearing arities land in later phase-1 steps
The next commit (1.6b) introduces an `objc_msg_send` IR opcode that
bundles (recv, sel, args, ret_ty) and emit_llvm builds a per-call-
site LLVM function type, sharing one declared `@objc_msgSend`
symbol across return-type variants. Five primitive returns
(*void / bool / s32 / s64 / f64) get folded in across 1.6b–c.
101/101 regression tests pass; the IR snapshot for the selector-
sharing test diff flips from four per-call `sel_registerName` calls
to two (one per unique selector) routed through a module-init
constructor — matching what clang emits for `@selector(...)`.
Hot-path cost collapses from a libobjc hashtable lookup per call to
a single load of a static `SEL*` slot:
Before (Phase 1.3):
%sel = call ptr @sel_registerName(<"init">)
call ptr @objc_msgSend(<recv>, %sel)
After (Phase 1.5):
%sel = load ptr, ptr @OBJC_SELECTOR_REFERENCES_init
call ptr @objc_msgSend(<recv>, %sel)
+ @OBJC_SELECTOR_REFERENCES_init = internal global ptr null
+ @OBJC_SELECTOR_REFERENCES_release = internal global ptr null
+ define internal void @__sx_objc_selector_init() {
+ %sel = call ptr @sel_registerName(ptr @OBJC_METH_VAR_NAME_)
+ store ptr %sel, ptr @OBJC_SELECTOR_REFERENCES_init
+ %sel1 = call ptr @sel_registerName(ptr @OBJC_METH_VAR_NAME_.2)
+ store ptr %sel1, ptr @OBJC_SELECTOR_REFERENCES_release
+ ret void
+ }
+ @llvm.global_ctors = appending global [1 x { i32, ptr, ptr }]
+ [{ ..., ptr @__sx_objc_selector_init, ptr null }]
Implementation:
module.zig | new `objc_selector_cache: ArrayList(ObjcSelectorEntry)`
with `lookupObjcSelector` / `appendObjcSelector`. List
(not hashmap) keeps emit order stable across builds so
the IR snapshot doesn't flicker on rehash.
lower.zig | `internObjcSelector(sel)` creates the slot on first
use, returns the same `GlobalId` on every subsequent
call to the same selector. lowerFfiIntrinsicCall now
emits `global_addr + load` for literal selectors.
Non-literal selectors keep the `sel_registerName`
fallback. Declaring `sel_registerName` lazily on
first intern so emit_llvm finds it for the
constructor body.
emit_llvm.zig | new `emitObjcSelectorInit` pass synthesizes a void
constructor that loops over the cache, calls
`sel_registerName` for each unique selector string,
stores the result in the slot. Constructor is
registered in `@llvm.global_ctors` with default
priority (65535) so dyld runs it before main.
The `@OBJC_METH_VAR_NAME_` private string globals and unnamed-addr
flag match clang's exact emission shape — picked up by the system
linker into the right Mach-O sections on macOS / iOS. Chess
Android + iOS-sim still build clean (no `#objc_call` in chess yet —
phase-3 migration will start exercising this).
run_examples.sh now supports an optional `tests/expected/<name>.ir`
sibling to `.txt`/`.exit`. When present, the runner also captures
`sx ir <file>` output, normalizes target-/host-specific noise
(module ID, target triple/datalayout, attribute groups, LLVM's
auto-suffixed %temp numbering), and diffs against the snapshot.
`--update` regenerates it alongside the runtime output.
Catches lowering changes that don't affect what the program prints
— exactly the shape Phase 1.5's selector interning will produce
(same runtime output, very different IR).
First snapshot: `ffi-objc-call-03-selector-sharing.ir`. Today the
test emits four `call ptr @sel_registerName(ptr @str.N)` lines for
its four call sites; after 1.5 we expect two static
`@OBJC_SELECTOR_REFERENCES_<sel>` globals + loads at each call
site. The diff between the two snapshots will be the visible
artifact of the optimization.
101/101 regression tests pass (+ffi-objc-call-03-selector-sharing).
Test exercises four call sites — three sharing "init" and one
"release" — to pin the multi-site / multi-selector lowering before
1.5 changes how SEL lookups are cached.
Runtime behavior: identical before and after 1.5 (all call sites
hit nil receivers; libobjc returns 0 for void). The improvement is
visible only in the emitted IR — today:
$ ./zig-out/bin/sx ir examples/ffi-objc-call-03-selector-sharing.sx \\
| grep -c "call ptr @sel_registerName"
4
After 1.5 (planned): 2 — one `sel_registerName` per unique selector
string, materialized into a static `OBJC_SELECTOR_REFERENCES_<sel>`
global at module init, then loaded at each call site. Matches the
shape clang produces for `@selector(...)`. Worth re-running the
above grep after 1.5 lands as a manual sanity check.
The IR-shape snapshot harness (auto-diff of `sx ir` output) is
deferred; for now we verify by eye.
100/100 regression tests pass; ffi-objc-call-02-void-return flips
from xfail (codegen rejection) to passing ("ok").
Lowering for `#objc_call(void)(recv, "selector:")` lands in
lower.zig as `lowerFfiIntrinsicCall`:
%sel = call ptr @sel_registerName(<"selector:">)
%call = call ptr @objc_msgSend(<recv>, %sel)
Two extern decls (`sel_registerName(*u8) -> *void` and
`objc_msgSend(*void, *void) -> *void`) are declared lazily and
cached on the Lowering instance via `objc_msg_send_fid` /
`sel_register_name_fid`, so multiple call sites share one
declaration each.
Phase 1.3 deliberately keeps scope tight: only `void` return + just
(recv, selector) arity is wired. Non-void returns + variadic arity
fall through with a diagnostic and are owned by subsequent phase-1
steps (1.6 primitive returns; 1.7..1.9 struct shapes; 1.10 multi-
keyword selectors).
Selector resolution is still per-call-site `sel_registerName` —
the planned 1.5 interning turns the per-call hashtable lookup into
a single static-global load. Chess Android + iOS-sim builds clean
— no regression on the existing typed-`objc_msgSend`-cast pattern.
100/100 regression tests pass (+ffi-objc-call-02-void-return xfail
snapshot).
The intrinsic with no `inline if false` guard reaches sema/codegen
and trips an "unresolved: 'unknown_expr'" — the FfiIntrinsicCall
AST node from Phase 1.1 has no lowering rules in lower.zig /
emit_llvm.zig yet.
nil receiver was chosen so the test doesn't need a real Obj-C
object graph: the runtime guarantees `[nil msg]` is a no-op with
zero result for void returns. macOS-gated via `inline if OS == .macos`
so the runner stays portable.
Next commit: emit_llvm.zig produces the per-call-site
%sel = call ptr @sel_registerName(ptr "init.0")
call void @objc_msgSend(ptr null, ptr %sel)
lowering. Snapshot flips to "ok". Selector interning (one shared
global per unique selector string) lands as a separate step (1.5).
99/99 regression tests pass (+ffi-jni-call-01-parse).
Locks in the same parse-surface contract for the JNI intrinsics
that ffi-objc-call-01-parse pins for the Obj-C side:
#jni_call(*void)(null, null, "getWindow", "()Landroid/view/Window;");
#jni_static_call(s32)(null, null, "max", "(II)I", 3, 7);
#jni_call(bool)(null, null, "isShown", "()Z");
All three lower through the shared `FfiIntrinsicCall` AST node
added in 1.1; only the kind tag distinguishes them. `inline if false`
keeps sema/codegen out of the picture until later phase-1 steps
wire those in.
98/98 regression tests pass; ffi-objc-call-01-parse flips from
parse-error xfail to passing.
Shape: `#<intrinsic>(ReturnT)(args...)`. The return-type generic
sits in the first parens, the actual call args in the second. All
three intrinsics share the same parse rule; only the kind tag and
the downstream lowering differ.
token.zig | three new hash_* tags
lexer.zig | matches the directive keywords with the same
isIdentContinue boundary check as the rest
ast.zig | FfiIntrinsicCall node with `kind`, `return_type`,
and `args` fields; FfiIntrinsicKind enum
parser.zig | parseFfiIntrinsicCall — same call-arg loop shape
as Call, with the leading return-type slot
sema.zig | analyzeNode + findNodeAtOffset arms walk the args
+ return-type child nodes
lsp/server.zig | classify the new tokens as ST.keyword
Codegen for the new intrinsic isn't wired yet — examples that
reach the body of a non-suppressed call would fail at lowering.
The current parse test uses `inline if false { ... }` to suppress
the dead branch, so sema/codegen don't see the node. Phase 1.3+
adds the lowering and the gate comes off.
Chess Android + iOS-sim builds clean — no regression on the
existing `objc_msgSend` cast pattern or the JNI helper.
98/98 regression tests pass (+ffi-objc-call-01-parse with xfail
snapshot capturing today's parse error).
Phase 1 of PLAN-FFI.md introduces three compiler intrinsics
(`#objc_call`, `#jni_call`, `#jni_static_call`) that lift the
ceremony off the existing typed-`objc_msgSend` and JNI dispatch
patterns. This is the first step of the cadence:
1.0 (this commit): test-add. Locks the current parse rejection.
1.1 (next): make-green. Parser accepts the new syntax;
this snapshot updates to whatever the next
pipeline stage produces (sema/codegen still
can't lower the intrinsic — that's later
phase-1 steps).
1.3+: codegen lands; the test eventually runs
cleanly against Foundation.
`inline if false` wraps the call site so the AST carries the node
but no codegen runs for it. Lets Phase 1.1's parse-only test pass
without dragging in the sema/codegen plumbing prematurely.
97/97 regression tests pass (94 expected updated; +issue-0037 from
the prior commit).
The companion `94-foreign-global-helper.sx` ALSO declares
`__stdinp : *void #foreign;`. Two sx files referencing the same
extern symbol must link cleanly — LLVM dedupes the named global at
the module level, and the C linker resolves both refs to the one
libSystem definition.
The full ergonomic story (helper computes the *same* address as the
main file's direct read) is blocked on issue-0037: lower.zig's
`address_of(global)` branch produces `undef` when the body is a
non-main function, even single-file. Once issue-0037 closes, fold
the helper's address back into an equality check here.
The cross-file link itself works today and is the lemma we're
locking in. This is also the closest thing today to the cross-file
extern-global ergonomic issue-0030 wants — `#foreign` already works
across files; the missing piece is sx-side `extern` decls for
sx-defined globals.
Repro found while writing PLAN-FFI step 0.10.
In a single file:
__stdinp : *void #foreign;
stdinp_addr :: () -> u64 { xx @__stdinp; }
main :: () -> s32 {
a : u64 = xx @__stdinp; // a = real symbol address
b := stdinp_addr(); // b = 0
...
}
The emitted IR for the helper is `ret i64 undef`, suggesting the
`address_of(identifier=__stdinp)` branch in lower.zig (~line 1719)
doesn't see `__stdinp` in `global_names` at the moment the helper's
body is being lowered — even though the same lookup succeeds inside
main's body in the same compilation unit.
Likely cause: lazy-body lowering ordering vs. the pass that
registers extern global decls into `global_names`. Worth verifying
which before fixing — could also be per-function scoping of the
map. Phase 1 of the FFI plan doesn't depend on this, so it stays
filed as an open issue and gets addressed when convenient (or when
sx-side `extern` cross-file globals from issue-0030 land and need
the same lookup to work everywhere).
`inline if OS == { case .macos: ... case .ios: ... else: ... }` is
already supported (see library/modules/platform/sdl3.sx:42 and
examples/38-build-config.sx:30). Cleaner than the chained
`inline if OS == .a; inline if OS == .b; ...` form the prior
commit used.
Same expected output — only the macOS arm survives codegen on the
host. Snapshot unchanged.
96/96 regression tests pass (+ffi-09-foreign-result-chain).
Opaque C-handle pattern that mirrors how real sx code threads
MTLBuffer*, AAssetManager*, file pointers, etc. through composite
sx values. C side has a trivial heap-int handle (`ffi_chain_make`
returning `void*`, `ffi_chain_bump` / `_peek` / `_dispose`). The sx
side exercises:
1. Chained calls — make -> bump -> bump -> peek; one handle
threaded through four FFI sites in sequence.
2. Struct field — `Counter { handle: *void; label: string; }`
hosts the handle; methods/accesses go through
`.handle` to feed back into C.
3. List(*void) — push N handles, iterate, peek each, iterate
again to bump each, iterate again to read
back. Catches any aliasing / lifetime breakage
when handles round-trip through the slice
backing of List.
95/95 regression tests pass (+ffi-08-foreign-in-method).
One trivial C helper (`ffi_method_helper`) called from each of the
major sx surface constructs that can host an FFI site:
1. struct method body Counter.next
2. protocol impl method body impl Doubler for Counter
3. closure value body make_adder's `closure(...)`
4. comptime-gated branch `inline if OS == .macos { ... }`
No new ABI shapes — the lowering route a `#foreign` call site takes
shouldn't depend on its enclosing construct, and the test pins that
lemma. A future lowering refactor that, say, breaks protocol-dispatch
fast-paths for FFI-calling impl methods will fail here directly
instead of being caught only by the chess Android regression.
The `inline if` branches for ios/linux compile down to nothing on
macOS, so only the macOS arm fires at runtime — useful smoke test
that the comptime gate works around FFI sites too.
vendors/ is a third-party namespace (stb_image, kb_text_shape, etc.);
test fixtures don't belong there. The .c/.h companion files for the
Phase-0 FFI baselines now sit alongside the .sx that drives them in
examples/, with matching basenames:
examples/ffi-01-primitives.{sx,c,h} <- was vendors/ffi_primitives/
examples/ffi-02-small-struct.{sx,c,h} <- was vendors/ffi_structs/
examples/ffi-03-large-struct.{sx,c,h} <- was vendors/ffi_large_struct/
examples/ffi-04-fp-struct.{sx,c,h} <- was vendors/ffi_fp_struct/
examples/ffi-05-string-args.{sx,c,h} <- was vendors/ffi_strings/
examples/ffi-06-callback.{sx,c,h} <- was vendors/ffi_callback/
examples/101-ffi-medium-struct.{sx,c} <- was vendors/ffi_medium_struct/
`#source` / `#include` paths in the .sx files become bare filenames
(no prefix) since imports.zig's base_dir resolution finds them
relative to the importing .sx file's directory.
`library/vendors/sx_ffi_resolve_test/` stays put — that one's the
whole point: regression coverage for the stdlib-search branch of
the resolution chain, so it must live where ONLY that branch can
find it.
94/94 regression tests pass.
94/94 regression tests pass (+ffi-07-c-import-block).
Companion C helper lives only at
`library/vendors/sx_ffi_resolve_test/`. Critically NOT in
`sx/vendors/` (the sx repo root) and NOT in the importing
example's directory — so the `vendors/...` paths in this
example are findable solely via the stdlib search branch
(`<exe>/../../library`, `<exe>/../library`, `<exe>/library`).
That branch is the one the JNI insets bridge needs to reach
`library/vendors/sx_android_jni/sx_android_jni.c` without
forcing chess (or any consumer) to vendor an identically-named
copy. The test pins the resolution end-to-end:
- #include resolves; clang parses the .h; c_import.zig
synthesizes #foreign fn decls for `sx_ffi_resolve_test_add` /
`_mul`.
- #source resolves; the .c is compiled into the build's
object list.
- sx calls the synthesized decls and prints results.
Latent bug from the stdlib-path resolution introduced in 4849cfb.
The earlier shape captured `const ci = decl.data.c_import_decl;`
BEFORE mutating `decl.data.c_import_decl.{sources,includes}` with
the resolved paths, then passed the stale `ci.includes` to
`c_import.processCImport`. Result: `#include "vendors/..."` paths
that resolved via the stdlib branch (i.e. only existed under
sx/library/vendors/) reached clang as the original unresolved
string and failed to parse — silently producing no synthesized
`#foreign` decls.
`#source` survived because the source list is re-read from
decl.data later (collectCImportSources walks the AST), so it
picked up the mutated value. Only `#include`'s synthesis path was
broken.
Fix: do the resolution first inside its own scope, then re-bind
`ci` from `decl.data.c_import_decl` so the include list passed to
processCImport sees the resolved paths.
Caught by ffi-07 baseline (next commit) — the test deliberately
puts its C helper only under library/vendors/ so the path is
findable solely via the stdlib chain.
93/93 regression tests pass (+ffi-06-callback).
Mirrors the `app->onInputEvent` install pattern from
library/modules/platform/android.sx:
1. (s32) -> s32 — single primitive arg/return
2. (*void, s32) -> s32 — opaque ctx pointer + value
(the onInputEvent shape)
Side effects via two file-level globals so the test observes both
the return value AND state mutation across multiple calls:
- g_callback_hits = N proves the callback fired N times.
- g_callback_sum = sum of args proves each individual call landed
with the correct value.
The ctx-pointer variant casts `*void` back to `*s32` inside the
callback and reads through it (`p.*`), proving the pointer survives
the round-trip with no aliasing weirdness.
92/92 regression tests pass (+ffi-05-string-args).
Covers the four shapes that actually appear at the sx ↔ C boundary
today:
1. [:0]u8 string literal -> const char* (ffi_strlen, ffi_first_byte)
2. sx `string` value via .ptr (slice-decay branch in
coerceArg pulls the pointer)
3. [*]u8 raw buffer + length (ffi_sum_bytes, mutated via
ffi_write_byte and read back)
4. C-returned const char* (round-trips back as [*]u8)
The mutate-via-C path catches any pointer-aliasing regression — sx
allocates the fixed array `bytes : [4]u8`, passes `.ptr` to C which
writes index 1, and the sx side reads `bytes[1]` to confirm the
mutation took effect through the same memory.
91/91 regression tests pass (+ffi-04-fp-struct).
Single-file regression net for the all-float / all-double aggregate
ABI path:
FQuad — 16 B, 4×f32 (same slot as ffi-02's Vec4f)
DQuad — 32 B, 4×f64 (UIEdgeInsets-shape — the f32-vs-f64 landmine)
Already nominally covered by ffi-02's Vec4f, but pinning it as a
focused single-file test means a future ABI rule change that breaks
the HFA path fails *this* test directly without a noisy drag-in from
the multi-shape baseline.
DQuad at 32 B straddles the AAPCS64 HFA limit (≤4 floats of same
type, total ≤64 B); it stays as a struct value passed through
v0..v3 rather than going indirect. The snapshot confirms the values
arrive intact.
90/90 regression tests pass (+ffi-03-large-struct).
vendors/ffi_large_struct/{.h,.c} defines:
Big24 — 24 B, three s64 (byval params + sret return)
Big48 — 48 B, six s64 (same path, larger)
`make / rotate-or-reverse / sum` helpers per shape. The sx-side
example imports via `#source` only and declares matching structs +
hand-written #foreign decls.
Snapshot pins today's >16-byte aggregate ABI now that the
emit_llvm.zig sret-return transform is in place (previous commit).
That gives us a regression net for all four C-ABI aggregate slots
in one place:
≤8 B int — i64 coercion (ffi-01 vec-likes)
9..16 B int — [2 x i64] coercion (ffi-02 Pair64/Quad32, 101)
16 B HFA — struct, no coercion (ffi-02 Vec2/Vec4f)
>16 B — byval params + sret (this commit)
Foreign functions that return a >16-byte non-HFA aggregate (e.g.
Big24 / UIEdgeInsets on iOS / clang-shaped struct returns) need the
indirect-return ABI: caller allocates space, passes its pointer as a
hidden first arg with `sret(<T>)`, callee writes through it and
returns void. AAPCS64 puts the pointer in x8; SysV AMD64 puts it in
the first int register and treats the named return as void.
The existing >16-byte branch in `abiCoerceParamType` was returning
`ptr` for BOTH params and returns. That works for byval params (the
established pattern — caller alloca + store + pass ptr, callee loads
in prologue), but is wrong for returns: it caused the function decl
to look like `ptr @fn(...)` rather than `void @fn(ptr sret(<T>), ...)`,
and the call site read whatever happened to be in x0 as a struct
pointer — segfault on dereference (caught while writing the ffi-03
baseline).
Fix layered into the same `abiCoerceParamType` / call-site code path:
emitFunctionDecl:
- Compute `uses_sret = needs_c_abi && needsByval(ret_ty, raw_ret_ty)`.
- Ret type collapses to void.
- Prepend a `ptr` param at slot 0.
- Add `sret(<RetType>)` type attribute on param-index 1
(LLVMAttributeIndex 1 = first parameter; 0 = return value).
.call lowering:
- Detect callee_uses_sret via the same predicate.
- Allocate the result on the caller's stack (`sret.slot`).
- Prepend it as args[0] (with sret_off index alignment so the
original sx args land at args[1..]).
- After LLVMBuildCall2, set the same `sret(<T>)` attribute on
the call site's arg 1 (mirrors the fn-decl attribute — both
land in the AArch64 backend's lowering pass).
- Load the result from the slot to produce the IR value.
`call_indirect` (function-pointer dispatch — uikit.sx's typed
`objc_msgSend` casts) keeps its existing behavior for now; the iOS
path already round-trips UIEdgeInsets via that route. Folding the
same sret transform into call_indirect is a follow-up.
89/89 regression tests still pass. Chess Android + iOS-sim both
build clean.
Now that emit_llvm.zig bridges the struct<->[2 x i64] ABI mismatch
(previous commit), the 9..16-byte integer-only shapes round-trip
cleanly. Extended `examples/ffi-02-small-struct.sx` to cover all
four aggregate ABI slots in one place:
Vec2 — 8 B, two f32 (register pair, float)
Vec4f — 16 B, four f32 (HFA — homogeneous float aggregate)
Pair64 — 16 B, two s64 (9..16 B int — [2 x i64] coercion slot)
Quad32 — 16 B, four s32 (same slot as Pair64)
Vendor helpers (`vendors/ffi_structs/{ffi_structs.h,ffi_structs.c}`)
grow `ffi_pair64_*` + `ffi_quad32_*` companions. Snapshot updated
to capture the full output. 89/89 regression tests pass.
`examples/101-ffi-medium-struct.sx` keeps a minimal focused repro
of the Pair64 case so the issue's emergence-and-fix history stays
greppable.
Resolves issue-0036 (LLVM verifier failure on 16-byte integer-only
struct by value through #foreign). The mismatch:
Call parameter type does not match function signature!
%load = load { i64, i64 }, ptr %alloca, align 8
[2 x i64] %call = call [2 x i64] @fn({ i64, i64 } %load)
`abiCoerceParamType` had already chosen `[2 x i64]` for 9..16-byte
non-HFA structs (the AAPCS64 / SysV AMD64 register-pair ABI slot for
that size class) on the foreign-decl side, but `coerceArg` only knew
how to bridge struct<->integer (the ≤8 B case) — not struct<->array.
LLVM's verifier rejects type-mismatched call args, so the call site
never landed.
Added the symmetric branches in coerceArg:
- Struct -> Array : alloca <array>; store <struct>; load <array>
- Array -> Struct : alloca <array>; store <array>; load <struct>
Both use the LLVM opaque-pointer memory-bitcast pattern already in
place for the integer case. They're paired with the existing
i64 <-> small-struct bridge so all four (≤8 B int, 9..16 B int,
16 B HFA, >16 B byval) ABI slots round-trip cleanly through
emit_llvm now.
File mechanics: promotes the issue-0036 repro to a focused feature
example per CLAUDE.md's issue-resolution workflow:
examples/issue-0036.sx -> examples/101-ffi-medium-struct.sx
tests/expected/issue-0036.{txt,exit} -> tests/expected/101-ffi-medium-struct.{txt,exit}
vendors/issue_0036/issue_0036.c -> vendors/ffi_medium_struct/ffi_medium_struct.c
Snapshot updated to the passing output. 89/89 regression tests pass;
chess Android build still clean.
Surfaced while writing the ffi-02-small-struct.sx baseline. The sx
#foreign decl lowers `{ s64, s64 }` (and other 16-byte integer-only
shapes like `{ s32, s32, s32, s32 }`) to `[2 x i64]` for the small-
struct register-pair ABI on AAPCS64 / SysV AMD64, but the call site
loads the struct as `{ i64, i64 }`. The two types must agree for the
LLVM verifier to accept the call:
Call parameter type does not match function signature!
%load = load { i64, i64 }, ptr %alloca, align 8
[2 x i64] %call = call [2 x i64] @issue0036_swap({ i64, i64 } %load)
Float-only 16-byte aggregates (e.g. Vec4f) work because they route
through the HFA path which keeps the struct representation. See
examples/ffi-02-small-struct.sx for the working cases.
Phase 1's #foreign lowering rework is the natural place to unify
these representations; check there before fixing inline.
88/88 regression tests pass (+ffi-02-small-struct).
vendors/ffi_structs/ defines:
Vec2 — 8 B, two f32 — register-pair (float) ABI
Vec4f — 16 B, four f32 — homogeneous float aggregate (HFA) on AAPCS64
Both pass cleanly today: the sx-side struct declarations match the C
ABI for these float-only shapes, and the call-site / foreign-decl
type representations agree.
`#source` only (no `#include`) — c_import's type mapping rewrites
struct-typed params/returns to *void, which would link but pass
through the wrong ABI silently. The hand-written #foreign decls keep
sx's struct types end to end.
16-byte integer-only shapes (`{s64, s64}`, `{s32, s32, s32, s32}`)
discovered to trip the LLVM verifier (`[2 x i64]` vs `{ i64, i64 }`
mismatch between foreign decl and call site). Excluded from this
baseline; filed separately in the next commit as issue-0036.
87/87 regression tests pass (was 86; +ffi-01-primitives).
vendors/ffi_primitives/{.h,.c} exposes a trivial identity roundtrip
per primitive C type — int/uint/short/ushort/long long/unsigned long
long/signed char/unsigned char/float/double/void* — plus two-arg
add helpers (int + double) for multi-arg ABI exercise. The sx-side
example imports the .h via `#import c { #include / #source }` and
prints each result; the snapshot in tests/expected pins today's
parameter + return ABI so Phase 1's #objc_call / #jni_call lowering
work can't silently regress primitive marshalling.
Two findings logged in current/CHECKPOINT-FFI.md's Known issues
section (current behavior, not new bugs): (1) c_import.zig maps
`signed char` -> `u8` not `s8`, and (2) sx integer-literal parser
rejects values >= 2^63 as overflow even when the receiver is u64.
Both worked around in this test without blocking the baseline.
First step of the FFI ceremony reduction plan (current/PLAN-FFI.md).
Iterates a (target, example) tuple list, runs `sx build --target <t>
<example>`, asserts exit 0 + output file produced. Cross-compile
correctness only — these examples can't run on the host.
Initial tuple list is empty, so the script exits 0 on a clean tree
and contributors without the iOS SDK / Android NDK aren't blocked.
`toolchain_available` short-circuits with a SKIP line when the
requested toolchain isn't installed. Phase 1/2/3 cross-only examples
populate TUPLES as they land.
Four Android UX wins landing together; all verified end-to-end on a
Pixel 7 Pro (board fills width, info-panel text renders, status bar
inset honored, tap-to-select + tap-to-move plays 1. e4).
- AndroidPlatform.init reads density via AConfiguration_getDensity
(app->config at offset 32) and sets dpi_scale = density / 160. The
hardcoded 1.0 had been making every logical unit equal one physical
pixel; ChessBoardView's 520-default size_that_fits fallback then
rendered at ~half the framebuffer width on the device, and glyphs
rasterized at literal 11-13 physical pixels were essentially invisible
on a 2340-tall display.
- gles3.sx set_scissor un-stubbed; with dpi_scale right the renderer
feeds in valid pixel bounds and the Y-flip math lands inside the
framebuffer.
- New library/vendors/sx_android_jni/sx_android_jni.c walks
activity -> window -> decorView -> rootWindowInsets via JNI and
publishes the system-bar insets. safe_insets() lazy-queries the
first call after EGL is up (decor view isn't attached at bootstrap).
- sx_android_install_input_handler sets app->onInputEvent; sx-side
sx_android_input_event translates AMotionEvent DOWN/MOVE/UP/CANCEL
into existing mouse_down/mouse_moved/mouse_up Events so the chess
board's tap-to-select + ScrollView drag path Just Works. Coordinates
divided by dpi_scale so layout-side hit tests match. poll_events
drains its slice after returning (mirrors the SDL pattern).
- src/imports.zig now routes #import c { #source / #include } paths
through the same chain as #import (importing dir -> CWD -> stdlib
search paths). Lets library-owned C helpers like the JNI bridge
live in sx/library/vendors/ without forcing consumers to vendor a
copy. Existing CWD-relative consumer layouts (chess's vendors/...)
still resolve first, so no regression.
86/86 regression tests pass.
platform/android.sx: `sx_android_bootstrap(app)` now also reads the
ANativeActivity's `assetManager` (offset 64) and `internalDataPath`
(offset 32) into module globals so consumers can route file I/O
through the APK's bundled `assets/` tree.
target.zig (`createApk`): also zips the project's `./assets/`
directory into the APK alongside `lib/<arch>/`. Resolves relative
to the user's CWD at invoke time — matches the convention chess
uses (assets/ next to main.sx).
gles3.sx: scissor is currently a no-op on Android. The renderer's
ScrollView clip_push path feeds bounds that land outside the
framebuffer (clipping everything off-screen). With scissor disabled
the chess board + pieces render correctly. TODO recorded in the
file to fix the bounds path properly.
Mirror of metal.sx, talks to GLES3 via opengl.sx's runtime-loaded
fn-pointer variables. EGL bootstrap is owned by AndroidPlatform; this
module just calls `load_gl(@eglGetProcAddress)` once during `init` to
populate the pointers, then drives raw draw/state from there.
The renderer's vertex layout (12 floats: pos2/uv2/color4/params4 = 48
bytes, attribute locations 0-3) is hardcoded in a single shared VAO
the Gles3Gpu owns — `set_vertex_buffer` rebinds the active VBO against
it. `set_vertex_constants(slot=1, data, 64)` is treated as the 4x4
projection matrix; `set_texture(slot=0, ...)` binds texture unit 0 and
sets `uniform sampler2D uTex` — both match renderer.sx's shader
contract.
A subtle gotcha caught + recorded in the file header: declaring the
same GL name as a `#foreign` function while opengl.sx also declares it
as an fn-pointer global silently lets the global win, and calling
through the uninitialized variable jumps to PC=0. Solution: don't
re-declare; use opengl.sx's pointers and `load_gl` them.
renderer.sx: the GPU-protocol shader-source branch now passes
(UI_VERT_SRC_ES, UI_FRAG_SRC_ES) on Android (separate vert+frag) vs.
the combined MSL library on iOS. Both gated with `inline if OS == X`.
C imports (stb_image, stb_truetype) compiled via the embedded LLVM
clang library now resolve bionic headers on Android. main.zig auto-
fills target_config.sysroot with the NDK root (mirroring the iOS path
that auto-fills the iOS SDK path); c_import.zig derives the bionic
sysroot inside it and passes `--sysroot <ndk>/toolchains/llvm/prebuilt/<host>/sysroot`
to the embedded clang.
Android link branch in target.zig stops auto-appending entries from the
collected `#library` / `#framework` lists. Most `#library` directives
in the stdlib (`objc.sx`'s `objc :: #library "objc";`, frameworks set
by uikit.sx, etc.) describe Apple-specific intent that's nonsensical on
Android. Users opt into Android-side libs via `opts.add_link_flag(...)`
in build.sx — same shape as how the iOS branch already lists frameworks
in chess's configure_build.
Verified end-to-end: chess game compiles for Android, packages into a
debug-signed APK, installs and launches on Pixel 7 Pro. It crashes in
UIRenderer.init because raw GL calls run before EGL is up — same
architectural gap iOS bridges via the Metal GPU protocol. Next step
is a GLES3 GPU impl or a lazy-init in UIRenderer. 86/86 regression
tests + iOS-sim chess build clean.
User writes BOTH `main` and a 3-line `android_main(app)` trampoline.
The library provides `sx_android_bootstrap(app)` (stashes the NDK app
pointer into a platform-owned global) and `AndroidPlatform` impl of
the Platform protocol. The library NEVER references `main` — the OS-
shape entry symbol lives in user code where the other entry symbols
already live. iOS / SDL3 keep their existing shape; only Android adds
the trampoline.
Cross-cutting bits this commit ships:
library/modules/compiler.sx
Add `android` variant to `OperatingSystem`.
src/ir/lower.zig
- injectComptimeConstants: map TargetConfig.isAndroid() → .android.
- New Pass 4 `checkRequiredEntryPoints`: emit a clean diagnostic
when `--target android` is requested but `android_main` isn't
defined, instead of letting the user crash on a dlopen-time
missing-symbol error.
library/modules/platform/android.sx
AndroidPlatform impl of the Platform protocol — EGL bringup on
`APP_CMD_INIT_WINDOW`, ALooper(0) polling, dispatches the user's
frame closure each ~16 ms tick. `sx_android_bootstrap(app)` is the
only function exposed for the entry trampoline.
examples/99-android-egl-clear.sx
Rewritten to use the new pattern: minimum `main` + `android_main`
pair, AndroidPlatform-driven render loop. Doubles as the usage
reference users hand off to the compiler diagnostic.
Verified on Pixel 7 Pro: purple clear-color frame, periodic
`rendered 60 frames` logcat lines. iOS-sim chess + 86/86 regression
tests pass.
When lowering `self.cb()` from inside a method whose receiver is *Self,
the field-access path passed the receiver pointer (not the aggregate)
to `structGet`, which then produced `call void undef(ptr undef)` at
the LLVM level — undefined at runtime, corrupted adjacent globals when
it transferred control to a garbage pointer. Auto-load through the
pointer first so structGet receives a real aggregate.
Discovered while building the new AndroidPlatform's `run_frame_loop` —
calling the stored frame closure as `self.frame_closure()` zeroed
out adjacent globals because the undef call jumped into random memory.
Added examples/100-closure-field-call-via-self-ptr.sx as the locked-in
regression: both direct (`self.cb()`) and hoisted (`fn := self.cb; fn();`)
forms must yield identical IR + behavior. 86/86 regression tests pass.
Verified on Pixel 7 Pro: solid purple frame, 'rendered 60 frames'
logcat line every second. End-to-end exercise of the new
sx-build → libsxhello.so → APK toolchain shipped today: NDK clang
link + native_app_glue bundling + aapt2/zipalign/apksigner pipeline +
isExportedEntryName so android_main lands in .dynsym.
Notes the source captures so future Android work doesn't repeat the
debugging:
- android_app field offsets for arm64 NDK 29 (window @ 72,
destroyRequested @ 100, source process fn-ptr @ 16).
- ALooper_pollOnce(-1, ...) blows the stack inside Looper::pollOnce
on this device/OS combo; ALooper_pollOnce(0, ...) is fine. We
drive the event loop non-blocking and sleep 16ms.
Outside the regression set on purpose (no tests/expected/99-*.txt) —
same convention as 63-metal-clear.sx. Build instructions live in the
file's leading comment.