Commit Graph

72 Commits

Author SHA1 Message Date
agra
f4b6cdae18 ffi 1.10: multi-keyword Obj-C selectors through #objc_call
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.
2026-05-19 18:53:19 +03:00
agra
794a49e938 ffi 1.9: 4×f64 HFA round-trip through #objc_call (UIEdgeInsets shape)
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.
2026-05-19 18:51:56 +03:00
agra
e388687f1a ffi 1.8b: sret transform for #objc_call(>16 B non-HFA struct)
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.
2026-05-19 18:50:26 +03:00
agra
865890aed9 ffi 1.8a: xfail — #objc_call(>16 B non-HFA) skips the sret transform
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
2026-05-19 18:45:57 +03:00
agra
af79a15422 ffi 1.7: small struct returns through #objc_call (≤16 B + HFAs)
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.
2026-05-19 18:44:14 +03:00
agra
d43385112c ffi 1.6: objc_msg_send IR opcode + per-call-site LLVM fn type
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).
2026-05-19 18:39:10 +03:00
agra
baeab179c3 ffi 1.6a: xfail — #objc_call with non-void return rejected today
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.
2026-05-19 18:02:43 +03:00
agra
b8a412ddc7 ffi 1.5: intern Obj-C selectors — one static SEL slot per unique name
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).
2026-05-19 13:09:34 +03:00
agra
26a04e49d0 tests: IR-snapshot harness — diff sx ir output when .ir present
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.
2026-05-19 13:01:28 +03:00
agra
c54ca755fa ffi 1.4: regression test for shared-selector #objc_call sites
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.
2026-05-19 12:59:13 +03:00
agra
f43dea6913 ffi 1.3 make-green: #objc_call(void)(recv, "sel:") codegen
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.
2026-05-19 12:56:53 +03:00
agra
d1e9def0c6 ffi 1.3: xfail end-to-end void-return #objc_call (no codegen yet)
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).
2026-05-19 12:48:38 +03:00
agra
83480b3a66 ffi 1.2: parser coverage for #jni_call and #jni_static_call
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.
2026-05-19 12:46:53 +03:00
agra
85bbb29e9e ffi 1.1: parser accepts #objc_call / #jni_call / #jni_static_call
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.
2026-05-19 12:45:49 +03:00
agra
fca7e9ce2a ffi 1.0: xfail parser test for #objc_call(T)(recv, "sel:", args...)
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.
2026-05-19 12:40:21 +03:00
agra
831b46ac35 ffi 0.10: extend 94-foreign-global with cross-file companion
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.
2026-05-19 12:06:08 +03:00
agra
161254f5bb issue-0037: @foreign_global from a helper function lowers to undef
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).
2026-05-19 12:05:55 +03:00
agra
608ff34d55 ffi 0.9: foreign-result chains — handle threaded through struct + List
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.
2026-05-19 11:59:18 +03:00
agra
d0ccb92ef7 ffi 0.8: #foreign call sites inside struct/protocol/closure/inline-if
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.
2026-05-19 11:57:44 +03:00
agra
c08a749043 ffi 0.7: #import c { #include / #source } via stdlib-path resolution
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.
2026-05-19 11:51:34 +03:00
agra
31ab175d56 ffi 0.6: C-to-sx callback baseline (1-arg + ctx-ptr forms)
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.
2026-05-19 11:48:34 +03:00
agra
31715bd251 ffi 0.5: string + byte-pointer baseline through #foreign
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.
2026-05-19 11:46:47 +03:00
agra
736382d39c ffi 0.4: focused FP-aggregate (HFA) baseline — FQuad + DQuad
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.
2026-05-19 11:44:43 +03:00
agra
2463eea1d4 ffi 0.3: large struct baseline (Big24, Big48) through sret return path
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)
2026-05-19 11:41:06 +03:00
agra
edd8689fb2 ffi 0.2: fold Pair64 + Quad32 back into small-struct baseline
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.
2026-05-19 11:32:36 +03:00
agra
7d2c2fb062 emit_llvm: bridge struct<->array ABI for 9..16-byte foreign structs
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.
2026-05-19 11:31:04 +03:00
agra
36e929101b issue-0036: 16-byte integer-only struct by value trips LLVM verifier
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.
2026-05-19 11:22:56 +03:00
agra
84b3fc8866 ffi 0.2: small struct baseline (Vec2, Vec4f) by-value through #foreign
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.
2026-05-19 11:21:16 +03:00
agra
bb80b7ca87 ffi 0.1: primitives baseline (#import c, one roundtrip per type)
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.
2026-05-19 11:15:13 +03:00
agra
7d2e579667 ffi 0.0: tests/cross_compile.sh scaffold
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.
2026-05-19 11:10:56 +03:00
agra
efb087559d ir: auto-deref *Self when invoking a Closure-typed field (issue-0035)
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.
2026-05-19 00:22:35 +03:00
agra
79419b99bd issue-0028: ?Protocol = null sentinel-shaped optional protocols
Protocol structs registered via registerProtocolDecl carry a new
is_protocol flag; the ?T paths in sizeOf/typeSizeBytes/toLLVMType
recognise it and lay out ?Protocol as the protocol struct itself
(ctx == null IS the "none" state), matching how ?Closure / ?*T are
sentinel-shaped — no extra storage.

Method dispatch on ?Protocol auto-unwraps in lowerCall's field-access
path; the unwrap is structurally a no-op so we just rebind obj_ty to
the payload type. resolveCallParamTypes extended for optional-protocol
receivers so enum-literal args (gpu.create_texture(.r8, ...)) get the
right target_type and don't silently collapse to tag=0 : s32 — same
issue-0031-class bug closed in Session 66, one type-system layer
deeper.

Library: UIRenderer / UIPipeline / GlyphCache migrated from the verbose
gpu: GPU = ---; has_gpu: bool pattern to gpu: ?GPU = null. set_gpu no
longer maintains a parallel bool flag.

Bundled: dock.sx threads delta_time as a struct field rather than via
a global pointer (cleanup unrelated to issue-0028, committed alongside).

Verified: 85/85 regression tests pass; iOS-sim chess + macOS chess
both render correctly post-migration.
2026-05-18 18:32:55 +03:00
agra
f9ecf9d00e iOS lock step keyboard + metal 2026-05-18 17:40:10 +03:00
agra
63565e41ff abi: pass >16B aggregates by ptr-in-next-reg (Apple ARM64 ABI) + Path B for fn-ptr casts
Three stacked compiler bugs were causing iOS-sim chess to crash inside
[MTLTexture replaceRegion:...]. Fixing them lets every replaceRegion call
site succeed (1×1 RGBA8, 1MB R8 atlas, 440×440 chess pieces).

Path B for callconv(.c) fn-pointer casts:
- FunctionInfo now carries call_conv: CallConv (TypeInfo.CallConv) so
  function-type interning distinguishes sx-CC from C-CC. Inst.zig's
  Function.CallingConvention aliases the same enum.
- Parser accepts an optional `callconv(.c)` suffix on fn-pointer type
  spellings (factored into parseOptionalCallConv() shared with parseFnDecl
  and parseLambda).
- resolveFunctionType passes the parsed CC through functionTypeCC().
- .call_indirect reads fp.call_conv == .c and applies the C-ABI
  alloca+materialize for >16B aggregate args (Path A's behaviour at .call).

Apple ARM64 ABI (drop LLVM byval):
- Side-by-side asm diff vs clang's emission for the equivalent C call site
  showed LLVM's `byval` attribute lowers Apple-arm64 byval on the stack,
  while clang passes the struct via a pointer in the next int register
  (x2 for replaceRegion:). The runtime objc_msgSend dispatch path expects
  clang's convention.
- Dropped the byval attribute from the function-signature emission and
  from both call sites (.call and .call_indirect). The materialize-into-
  alloca + pass-plain-ptr pattern stays — the call site now matches
  clang's `mov x2, sp` exactly.
- Path A's sx-to-sx case continues to work since both ends use plain ptr
  (caller does alloca+store+pass, callee loads from the ptr in prologue).

Protocol dispatch (emitProtocolDispatch):
- Untargeted `null` lowers as const_null with type .void (per
  target_type orelse .void). The "wrap-value-in-alloca-pass-pointer"
  branch alloca'd a void slot, which LLVM's IRBuilder asserts on —
  EXC_BREAKPOINT in getTypeSizeInBits, manifesting as exit 133 / SIGTRAP
  when building the chess game. Fixed by re-emitting as
  constNull(void_ptr) when arg_ty == .void && expected_ty == void_ptr.
- is_pointer_ty only recognized .pointer, so [*]T (many_pointer) was
  alloca-wrapped — the heap pixels pointer from stbi_load was stored
  into a stack slot and the slot's address was passed as the *void arg.
  Fixed by extending the check to `.pointer or .many_pointer`.

metal.sx call sites + lifecycle guards:
- msg_replace (replaceRegion:, MTLRegion = 48B) and the two setScissorRect:
  sites (MTLScissorRect = 32B) now spell their fn-pointer types with
  by-value params + callconv(.c) — the *MTLRegion/@local workaround is
  gone.
- metal_begin_frame_ios bails before nextDrawable when pixel_w/h are 0
  (drawableSize 0×0 makes nextDrawable abort via XPC).
- metal_init_ios only sets drawableSize when dims are positive.
- begin_frame's encoder/cmd_buffer failure paths now clear self.drawable
  so a partial failure doesn't leak a drawable back into the pool.

Examples + tests:
- examples/86-callconv-c-fnptr-large-aggregate.sx — new, covers Path B
  with C-CC fn-ptr cast.
- examples/87-fnptr-cast-large-aggregate.sx — renamed from issue-0025.sx,
  covers Path B with default sx-CC (the negative case).
- examples/85-cc-c-large-aggregate.sx — from Session 60, covers Path A.
- examples/issue-0014.sx, issue-0024.sx, issue-0025.sx — removed
  (resolved earlier this work).

71 regression tests pass, 0 failed. Chess game builds clean for iOS sim
and reaches its frame loop without aborting. Runtime: chess UI still
doesn't render — remaining issue is in the UIKit lifecycle / CAMetalLayer
setup (legacy-app vs scene-API hybrid), not a compiler bug. See
current/CHECKPOINT.md "Next step" for the diagnosis + options.
2026-05-18 00:11:23 +03:00
agra
a938c4f900 metal: GPU protocol + MetalGPU renders MSL triangle on iOS
Phase 8 step 3a of the Metal renderer port:

- New library/modules/gpu/ with types.sx (handles + ClearColor +
  TextureFormat enum), api.sx (GPU :: protocol { ... } covering the
  lifecycle / per-frame / resource / per-draw surface), and metal.sx
  (MetalGPU backend implementing the protocol against CAMetalLayer).
  Resource handles are 1-based indices into backend List(*void) tables.
  MTL aggregates >16 bytes (MTLRegion, MTLScissorRect) pass via *T to
  match arm64 Apple's indirect-by-reference ABI; MTLClearColor + CGSize
  go through the HFA path as direct fn-pointer casts on objc_msgSend.

- UIKitPlatform got a gpu_mode: GpuMode toggle + sibling SxMetalView
  class registration. In metal mode init skips EAGL context, the
  did_finish_launching IMP skips the EAGL drawable-properties dict,
  layoutSubviews reads the layer's bounds * dpi_scale into pixel_w/h
  instead of allocating a GL renderbuffer, and end_frame is a no-op
  (the MetalGPU owns its own present).

- examples/63-metal-clear.sx verifies the pipeline end-to-end on iOS
  sim — compiles a pass-through MSL shader (packed_float2/packed_float4
  to avoid alignment padding), uploads 3 vertices, draws a colored
  triangle on a dark-blue clear.

Compiler fixes (filed-and-fixed in this branch):

- inline if X { return E; } followed by a fall-through final expression
  no longer emits two terminators into the same basic block. Verified
  by examples/83-inline-if-return-fallthrough.sx.

- Top-level type alias Name :: u32; now resolves correctly as the type
  annotation on a global variable (was treated as ptr {}, breaking
  comparisons + initializers). Verified by examples/84-global-type-alias.sx.

Issue->feature promotion:

- 16 historical examples/issue-NNNN.sx repros now confirmed-fixed and
  renamed to focused feature names (67-82). Each gains a
  tests/expected/*.txt + .exit pair so the regression suite covers them.

- 5 stale issue repros deleted (subsumed by broader tests).

Regression suite: 68 passing, 0 failed. macOS chess builds + runs; wasm
chess builds; iOS sim GLES chess still renders the full board; iOS sim
Metal demo renders the triangle.
2026-05-17 19:36:37 +03:00
agra
1c32d54e01 ios + ir cleanup
- ios: --target ios/ios-sim shorthands, iOS SDK auto-discovery,
  #framework directive + BuildOptions.add_framework hook,
  .app bundle + Info.plist + codesign (ad-hoc and real),
  --codesign-identity/--provisioning-profile/--entitlements flags,
  modules/std/{objc,uikit}.sx, dynamic class registration,
  typed objc_msgSend cast pattern, UIApplicationMain handoff,
  UIWindow scene attach. Runs on iPhone hardware.
- ir: silent .s64 defaults → loud diagnostics,
  resolveReturnType infers from body, sub-byte int sizes match LLVM,
  tuple type interning includes names, compile errors exit 1
- issue-NNNN convention: resolved bugs rename to focused features
- 50 regression tests passing
2026-05-17 13:19:08 +03:00
agra
f9dda972d2 fixes 2026-03-05 16:20:36 +02:00
agra
22bc2439ce fixes 2026-03-04 17:12:56 +02:00
agra
004aff5f67 wasm shell + destructuring 2026-03-03 13:21:54 +02:00
agra
6c5672c7df trailing commas 2026-03-03 09:42:01 +02:00
agra
bbb5426777 sm 2026-03-02 21:00:55 +02:00
agra
2f4f898d54 asm... 2026-03-02 17:19:41 +02:00
agra
f763765ea2 ir done'ish 2026-03-01 22:38:41 +02:00
agra
6a920dbd2c ir 2026-02-28 18:03:38 +02:00
agra
2552882ce6 05 2026-02-26 14:46:21 +02:00
agra
7209e8e69d ... 2026-02-26 00:18:36 +02:00
agra
f0569a8a3e dot-shorthand and more 2026-02-25 15:51:22 +02:00
agra
4abc7abb54 auto type erasure for protocols & struct literal init bloc 2026-02-25 12:25:06 +02:00
agra
bfc784734c lsp import 2026-02-24 20:05:24 +02:00
agra
06d10541da protocols 2026-02-24 19:46:17 +02:00