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).
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).
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.