`src/ir/jni_java_emit.zig`'s `emitJavaSource` takes a
`ForeignClassDecl` with `is_main = true` and returns the `.java`
source text. AOT pipeline integration (javac + d8 + APK bundling +
manifest synthesis + RegisterNatives) lands in subsequent slices.
Emission shape per bodied method:
@Override
public <ret> <name>(<params>) {
super.<name>(<args>);
sx_<name>(<args>);
}
private native <ret> sx_<name>(<params>);
Declaration-only methods (no body — references inherited Java
methods that sx wants to *call*) are skipped — no override, no
native delegate.
`#extends Alias` resolves through the supplied class registry to
the parent's foreign Java path. Default parent is
`android.app.NativeActivity` when `#extends` is absent.
Type matrix: primitives (void/bool/s8..s64/u8/u16/f32/f64), `*Self`
elided as the receiver (Java's implicit `this`), `*void` as
`Object`, `*Foo` cross-class refs resolved through the class
registry.
Six unit tests cover: non-main rejection, full void onCreate(Bundle)
shape with @Override delegate, primitive params, declaration-only
skipping, `#extends Alias` resolution, default-package classes.
130/130 examples still green; zig test clean.
`#jni_call` collapses to a single surface — env is *always* implicit:
either picked up from the lexically-enclosing `#jni_env(env) { ... }`
block's Ref (cheap, register-resident, no TL touch) or from the
runtime's thread-local slot via `sx_jni_env_tl_get()` (one fn call
per dispatch). The explicit-env shape is gone — chess and the
existing tests migrate cleanly by wrapping their helper-fn bodies
in `#jni_env(env) { ... }`.
The TL slot lives outside the user's IR module so the LLVM ORC JIT
can load object files cleanly without `orc_rt` for TLS support:
library/vendors/sx_jni_runtime/sx_jni_env_tl.c:
static _Thread_local void *sx_jni_env_tl_slot;
void *sx_jni_env_tl_get(void) { return sx_jni_env_tl_slot; }
void sx_jni_env_tl_set(void *env) { sx_jni_env_tl_slot = env; }
Linkage:
- sx-the-compiler links the .c file via build.zig so the JIT
process-symbol generator resolves `sx_jni_env_tl_get`/`_set`.
- AOT targets get the same .c file auto-linked via the lowering
pass: when lower touches the TL externs, it sets
`needs_jni_env_tl_runtime`, and `Compilation.lowerToIR` appends a
synthetic `CImportInfo` to `lowering_extra_c_sources` that
`collectCImportSources` merges with user-written ones.
Lowering-side changes:
- `getJniEnvTlFids` lazily declares the two externs (parallel
to `getSelRegisterNameFid`) and flips `needs_jni_env_tl_runtime`.
- `#jni_env(env) { body }` emits save→set→body→restore via three
`call` ops to the externs; the inner body sees env via the
lexical-direct stack.
- `lowerJniCall` resolves env from `jni_env_stack` (top) or the TL
fallback. The explicit-env branch is gone.
- `jni_env_stack_base` tracks per-fn lexical scope so lazy-lowering
a callee doesn't accidentally see the caller's Ref (Refs are only
valid inside one fn's instruction stream).
Test migration (mechanical):
- ffi-jni-call-{01..09}: each helper fn wraps `#jni_call(...)`
bodies in `#jni_env(env) { ... }`. Returning values pass through
the block as an expression — `#jni_env` now also lowers in
expression position.
Verified:
- zig build test + tests/run_examples.sh: 130/130 green.
- tests/cross_compile.sh: 3/3 green.
- Chess APK rebuilt + reinstalled on Pixel. Board renders with
status-bar clearance + info panel intact; no crashes in logcat.
Safe-insets dispatch through `#jni_env` + lexical-direct now
fully exercised end-to-end on real hardware.
Flip the surface semantics for type-introducer directives: bare
`Foo :: #jni_class("path") { ... }` now means "DEFINE a new Java class
at that path" (sx-side provides the implementations). The `#foreign`
prefix modifier flips it back to "REFERENCE an existing class on the
foreign runtime." Matches how `#foreign` already reads in sx for C
function declarations (`printf :: ... #foreign;`).
Foo :: #foreign #jni_class("path/to/Foo") { ... } // reference
Foo :: #jni_class("path/to/Foo") { ... } // define
Foo :: #jni_main #jni_class("path/to/Foo") { ... } // define + main Activity
Compiler-side changes:
- New `hash_jni_main` lexer token (the launchable-Activity marker).
Existing `hash_foreign` is reused; no new modifier token there.
- `ForeignClassDecl` gains `is_foreign: bool` + `is_main: bool`.
`ForeignMethodDecl` gains `body: ?*Node` so defined-class methods
can carry sx-side implementations (foreign-class methods stay
`;`-terminated).
- Parser learns `tryParseForeignClassPrefix` — peek-and-consume the
modifier tokens, then dispatch to the unchanged
`parseForeignClassDecl` with the flags threaded through.
- Sema rejects two illegal combinations: `#foreign + #jni_main`
(can't be both an external reference and the app's main entry),
and bodied methods on `#foreign` decls (foreign methods are
runtime-provided).
- Lower's foreign-class dispatch errors on non-foreign decls with
a pointer to the runtime-synthesis follow-up; defined-class
codegen (Java class emission, RegisterNatives wiring, manifest
entry generation) lands in a separate session.
Migration:
- `library/modules/platform/android_jni.sx`: all four foreign class
decls (`Activity`, `Window`, `View`, `WindowInsets`) gain `#foreign`.
- `examples/ffi-jni-class-{01..08}*.sx`: every test's `#jni_class` /
`#jni_interface` / `#objc_class` / `#objc_protocol` / `#swift_class`
/ `#swift_struct` / `#swift_protocol` usage gains `#foreign`. All
9 files mechanical perl rename; snapshots unchanged.
Verified locally:
- `zig build test` clean.
- `bash tests/run_examples.sh` 129/129.
- `bash tests/cross_compile.sh` 3/3.
- Chess APK rebuilds, reinstalls, launches on Pixel; safe-area
clearance preserved.
The four foreign-class declarations move into a new sub-module
`library/modules/platform/android_jni.sx`, imported under a named
namespace from `android.sx`:
Jni :: #import "modules/platform/android_jni.sx";
This keeps the bare class names (`Activity`, `Window`, `View`,
`WindowInsets`) out of the top level — consumers that flat-import
`modules/platform/android.sx` no longer see `View` collide with
`modules/ui/view.sx`'s protocol of the same name (chess hit this
on the first build attempt).
Compiler-side change: `scanDecls`/`lowerDecls` now also iterate any
`namespace_decl` they encounter and register the contained
`foreign_class_decl`s under their qualified name (`Jni.Activity`).
The recursive scan continues to register the bare names too, so
cross-class refs inside method signatures (e.g. `getWindow ::
(self: *Self) -> *Window`) still resolve through the bare key.
Receiver types like `*Jni.Activity` now route through
`getStructTypeName` → "Jni.Activity" → `foreign_class_map` lookup.
`sx_query_safe_insets_jni`'s param signature changes from
`activity: *Activity` to `activity: *Jni.Activity`; the caller in
`AndroidPlatform.safe_insets` casts via `xx`.
Verified on-device — chess APK built with the new sx, installed via
`adb install -r`, launched on the Pixel. Screencap shows the board
rendering with correct status-bar clearance (time + battery icons
visible above the board, board sized below them) — safe insets are
being queried via the new declarative dispatch and produce the same
values as the pre-migration hand-rolled #jni_call chain.
129/129 examples + cross_compile 3/3 + on-device chess all green.
`inst.method(args)` on a value typed as a foreign-class alias
(`Activity :: #jni_class("android/app/Activity") { getWindow ::
(self: *Self) -> *Window; }` etc.) now lowers to `jni_msg_send`
with descriptor auto-derived from the sx signature, env from the
enclosing `#jni_env` scope (lexical-direct via 2.16b), and slot
interning re-used from Phase 1C.
Touch surface:
- `Lowering` gains `foreign_class_map: StringHashMap(*const
ForeignClassDecl)` populated in `scanDecls` + `lowerDecls`.
- New `registerForeignClassDecl` records each declared alias; the
type-bridge fallback already interns the alias as a 0-field
struct, so `*Activity` resolves cleanly through `getStructTypeName`.
- New `lowerForeignMethodCall` looks up the method in
`ForeignClassDecl.members`, derives the descriptor via
`jni_descriptor.deriveMethod` (with a `ClassRegistry` built from
`foreign_class_map`), and emits `jni_msg_send` directly. Filters
by runtime — `jni_class`/`jni_interface` lower; `objc_class` etc.
surface a clear "not yet supported" diagnostic until Phase 3/4.
- `lowerCall`'s method-dispatch arm inserts the foreign-class
check before the standard struct-method resolution.
JNI descriptor derivation gains `*void → Ljava/lang/Object;` (the
opaque-jobject convention) — common when sx code doesn't have a
precise Java type for the value. Locked in with a unit test.
IR snapshot at `tests/expected/ffi-jni-class-08-call.ir` shows the
full lowering: env from the enclosing fn param, target from the
foreign-class arg, slot-interned `(class, method, sig)` cache
pair, jni_msg_send to `CallObjectMethod` (slot 34). Mangled slot
names `@SX_JNI_CLS_getWindow____Ljava_lang_Object_` confirm the
derived descriptor.
129/129 examples + 16 jni_descriptor unit tests green.
`Lowering` gains a `jni_env_stack: ArrayList(Ref)`. When lowering
the `jni_env_block` arm pushes the env_expr's Ref before lowering
the body and pops after; `defer` ensures cleanup on early return.
`lowerJniCall` now disambiguates explicit-vs-omitted env via the
position of the first string-literal arg: at index 1 → omitted
(3-arg form `target, "name", "sig"`), at index 2 → explicit
(4-arg form `env, target, "name", "sig"`). Omitted form reads the
top of `jni_env_stack`; missing scope → diagnostic.
End-to-end test runs cleanly. Locked-in IR snapshot at
`tests/expected/ffi-jni-env-02-lexical-direct.ir` shows env coming
from the enclosing fn's `*void` param straight into the jni_msg_send
expansion — no extra load, no thread-local read. The hot-path
optimisation from the design discussion is now real.
128/128 examples + 1 new IR snapshot green; zig test clean.
New `hash_jni_env` lexer token; `parsePrimary` dispatches to a small
`parseJniEnvBlock` that consumes `(env) { body }` and returns a new
`JniEnvBlock` AST node (env_expr + body block).
Sema's analyzeNode arm recurses into env + body inside a pushed
scope; findNodeAtOffset descends through both children for go-to-
definition.
Lowering treats it as a syntactic wrapper around the block: env is
evaluated for side effects, body lowers as a normal block. The TL
push/pop semantics (synthesizing the env stack so `#jni_call`'s env
arg can become optional) land in 2.16b.
`expectSemicolonAfter` recognises `jni_env_block` as block-form so
statement-position uses don't need a trailing `;` — matches `if` /
`while` / `for` / bare blocks.
Test runs through the block body and prints expected output; xfail
snapshot flips to green. 127/127 examples green.
When a `ForeignMethodDecl` carries a non-null
`jni_descriptor_override` (parsed in step 2.6), `deriveMethod`
short-circuits — auto-derivation is skipped entirely and the override
is returned (duplicated through the caller's allocator so ownership
semantics stay uniform regardless of which branch ran).
Two new tests: override beats normal derivation, and override
bypasses cross-class refs that would otherwise fail with
`UnknownClassAlias`. Confirms the escape-hatch semantics from 2.6 —
users can paste an explicit JNI signature when auto-derivation
doesn't match (synthetic methods, ambiguous overloads, unknown-to-sx
JVM internals).
15 jni_descriptor tests pass; 126/126 examples still green.
`Context` gains an optional `classes: ?*const ClassRegistry` lookup
(sx alias → foreign path). `writeType`'s pointer-type arm now treats
`*Self` and `*Foo` uniformly: `Self` resolves to `enclosing_path`,
any other named target is looked up in the registry. Missing
registry or missing key both surface as `UnknownClassAlias`.
`DeriveError.CrossClassRefNotYetSupported` retired in favour of
`UnknownClassAlias` — the new error fires for both
"no-registry-provided" and "alias-not-in-registry", giving the
caller (later, sema with a real diagnostic) one error variant to
handle.
Four new unit tests: cross-class resolves with registry, errors
without registry, errors with empty registry, and end-to-end
`deriveMethod` with chained `*Self`/`*Foo` (`getDecorView ::
(self: *Self) -> *View → ()Landroid/view/View;`).
13 jni_descriptor tests pass; 126/126 examples still green.
New `src/ir/jni_descriptor.zig`:
- `writeType(allocator, buf, ctx, type_node)` appends one JNI
descriptor for an sx type AST node.
- `deriveMethod(allocator, ctx, method)` returns the full
`(args)ret` descriptor for a `ForeignMethodDecl`, skipping the
implicit `self` for instance methods.
- `Context.enclosing_path` resolves `*Self` to its `L<path>;` form.
Primitive table: void→V, bool→Z, s8/u8→B, s16→S, u16→C, s32→I,
s64→J, f32→F, f64→D. Arrays: `[]T` / `[*]T` / `[N]T` → `[<elem>`.
`*Self` → `L<enclosing>;`. Cross-class `*Foo` → explicit
`CrossClassRefNotYetSupported` error (lands in step 2.9 with the
ForeignClassDecl registry lookup).
Tests in `src/ir/jni_descriptor.test.zig`: primitive table coverage,
void-on-null, *Self, slice, cross-class-fail-fast, plus three
deriveMethod scenarios (instance, static, multi-param, slice param).
Step 2.8 is internal compiler work — derivation isn't observable at
the sx surface until call-site lowering at step 2.11. The cadence
rule's xfail-then-green pattern presupposes a snapshot harness that
doesn't apply to internal-only functions; the rule re-applies at
2.11 where end-to-end observation returns.
zig build test passes; 126/126 examples still green.
Static dispatch wired in. The early `is_static` bail in
`.jni_msg_send` is gone; both paths now share the same lazy-cache +
phi structure with two static-specific differences:
1. `GetObjectClass` is skipped — for static calls, `target` IS the
`jclass`. The cached `cls` slot just stores `NewGlobalRef(target)`
directly.
2. The method-ID lookup uses `GetStaticMethodID` (slot 113), and the
dispatch uses `CallStatic<Type>Method` (Object 114 / Boolean 117
/ Int 129 / Long 132 / Float 135 / Double 138 / Void 141).
Slot interning still applies: the `@SX_JNI_{CLS,MID}_<key>` pair is
shared between instance and static literal call sites with the same
`(name, sig)` — though in practice the JNI runtime treats instance
and static method-IDs as distinct, so two sites with the same name
but different dispatch kinds would collide in the cache. This isn't
a problem the chess Android backend hits (each method is uniquely
either static or instance in the API), so the simpler single-key
intern stays.
IR snapshot updated: `ret i32 undef` replaced by the full
NewGlobalRef → GetStaticMethodID → CallStaticIntMethod sequence
through vtable slots 21, 113, 129. Args `i32 3, i32 7` thread through
the existing arg-coercion loop.
Closes the return-type matrix. Pointer-return types aren't a simple
`TypeId` enum case (they're user-defined types interned into the
table), so the dispatch checks `TypeInfo.pointer | .many_pointer`
ahead of the primitive switch:
const is_pointer_ret = switch (types.get(ret_ty_id)) {
.pointer, .many_pointer => true,
else => false,
};
const offset = if (is_pointer_ret)
Jni.CallObjectMethod
else switch (ret_ty_id) { .void => ..., .s32 => ..., ... };
LocalRef cleanup deferred: returned jobjects are JNI LocalRefs
bounded by the native frame. Chains of calls within one frame
consume them inline; cross-frame use must promote via `NewGlobalRef`
(already wired in the slot-interning path from 1.17). The chess
Android backend will consume objects inline, matching the manual
pattern in `sx_android_jni.c`.
Return-type matrix done: void, s32, s64, f64, bool, *void all
dispatch through their respective vtable slots. Static dispatch
(1.23) is next.
One-line addition: `.bool => Jni.CallBooleanMethod`. The lazy-cache
+ dispatch from 1.17 handles the rest. JNI's `jboolean` is i8 in the
C ABI but always carries 0 or 1; LLVM's call boundary truncates the
return byte to i1 and the sx-level bool reads the low bit
canonically.
IR snapshot updated: `ret i1 undef` replaced by the full sequence
through vtable slot 37 keyed on `("isShown", "()Z")`.
One-line addition to the switch: `.f64 => Jni.CallDoubleMethod`.
First non-integer JNI return type; same lazy-cache + dispatch
infrastructure from 1.17 handles the rest.
IR snapshot updated: `ret double undef` replaced by the full
sequence through vtable slot 58 keyed on `("getValue", "()D")`.
One-line addition to the `call_method_offset` switch: `.s64 =>
Jni.CallLongMethod`. The 1.17 caching infrastructure and the named-
constants struct from c1877fc handle the rest.
IR snapshot at `tests/expected/ffi-jni-call-05-jlong-return.ir`
updated: `ret i64 undef` replaced by the full lazy-cache +
CallLongMethod (vtable slot 52) sequence keyed on
`("currentTimeMillis", "()J")`.
The numeric slot indices (21, 31, 33, 49, 61) in the `#jni_call`
lowering are JNI-spec constants from `<jni.h>` but appeared as bare
magic numbers — only the trailing comment told you which JNI
function you were loading. Moving them into a private `const Jni`
namespace at file scope makes the call sites self-documenting:
loadJniFn(ifs, Jni.GetObjectClass, "jni.GetObjectClass")
loadJniFn(ifs, Jni.NewGlobalRef, "jni.NewGlobalRef")
loadJniFn(ifs, Jni.GetMethodID, "jni.GetMethodID")
switch (ret_ty_id) {
.void => Jni.CallVoidMethod,
.s32 => Jni.CallIntMethod,
...
}
Also pre-loaded the remaining Call<Type>Method slots (Object,
Boolean, Long, Float, Double) so steps 1.19–1.22 just add the
corresponding switch arm — no new magic-number lookups in the diff.
Behavior-preserving refactor: IR snapshots unchanged, all 113 host
tests still pass, both cross-compile tuples still green.
One-line addition to the `call_method_offset` switch in
`emit_llvm.zig` — `.s32 => 49` (CallIntMethod). The 1.17 caching
infrastructure handles the rest: GetObjectClass → NewGlobalRef →
GetMethodID populate the shared `@SX_JNI_{CLS,MID}_<key>` pair on
miss; per-call lowering loads the cached jmethodID and dispatches
through vtable slot 49 with an `i32` return.
IR snapshot at `tests/expected/ffi-jni-call-04-jint-return.ir`
updated: the `ret i32 undef` placeholder is replaced by the full
lazy-cache + CallIntMethod sequence keyed on
`("getCount", "()I")`. Pre-1.18 snapshot was 1d7ea72.
Two `#jni_call` sites with the same string-literal `(name, sig)` pair
now share a single `jclass` GlobalRef slot and a single `jmethodID`
slot, populated lazily on the first call to any matching site.
Non-literal sites keep the per-call `GetObjectClass` + `GetMethodID`
sequence from step 1.15.
Per-call-site lowering for literal sites:
%cached_mid = load ptr, @SX_JNI_MID_<key>
%is_cached = icmp ne ptr %cached_mid, null
br i1 %is_cached, cont, miss
miss:
%local_cls = GetObjectClass(env, target)
%global_cls = NewGlobalRef(env, local_cls) ; vtable slot 21
store ptr %global_cls, @SX_JNI_CLS_<key>
%fresh_mid = GetMethodID(env, global_cls, name, sig)
store ptr %fresh_mid, @SX_JNI_MID_<key>
br cont
cont:
%mid = phi ptr [%cached_mid, before], [%fresh_mid, miss]
call <Type>Method(env, target, %mid, args...)
Wiring:
- `JniMsgSend.cache_key: ?CacheKey` (new) carries `(name_str,
sig_str)` when both `name` and `sig` are string-literal AST nodes;
empty for non-literal call sites.
- `lower.zig` populates `cache_key` from the AST.
- `emit_llvm.zig` `getOrCreateJniSlots(name, sig)` returns the
`{cls_slot, mid_slot}` pair, creating and caching them on first
lookup. Key is `name\x00sig` so the separator can't collide with
any JNI identifier byte.
- `mangleJniKey` builds an LLVM-identifier suffix from the pair, used
in the `@SX_JNI_{CLS,MID}_<suffix>` global names.
IR snapshot at `tests/expected/ffi-jni-call-03-methodid-sharing.ir`
updated: two call sites against literal `("noop", "()V")` now share
`@SX_JNI_CLS_noop____V` and `@SX_JNI_MID_noop____V`. Pre-1.17 snapshot
had two independent `GetMethodID` calls; post-1.17 has one global
slot pair plus per-call lazy-init branches.
Note: an unrelated regression in `examples/ffi-objc-call-12-rect-u64-returns.sx`
exists in the working tree (parse error from an in-progress C-import
block) and is left untouched.
New `.jni_msg_send` IR opcode carrying `{env, target, name, sig,
args[], is_static}`. `lowerFfiIntrinsicCall` now dispatches on
`fic.kind`: `.objc_call` keeps the existing path; `.jni_call` and
`.jni_static_call` route through `lowerJniCall`, which emits the new
opcode.
emit_llvm.zig expands `.jni_msg_send` into the JNI vtable
indirection:
%ifs = load ptr, %env ; vtable
%get_obj_class = load ptr, gep(%ifs, i32 31)
%cls = call ptr %get_obj_class(%env, %target)
%get_method_id = load ptr, gep(%ifs, i32 33)
%mid = call ptr %get_method_id(%env, %cls, %name, %sig)
%call_void_method = load ptr, gep(%ifs, i32 61)
call void %call_void_method(%env, %target, %mid, args...)
Per step 1.15's scope: only `.jni_call` (instance) + `void` return
are wired through the switch. `.jni_static_call` (1.23) and the
non-void returns (1.18–1.22) drop to a placeholder `LLVMGetUndef` so
the build doesn't fault — the next-step commits flip those arms one
shape at a time. Method-ID caching is step 1.17.
Two small helpers landed alongside:
- `loadJniFn(ifs, offset, name)` — GEP into the vtable + load.
- `extractSlicePtr(val)` — string literals lower as `{ptr, i64}`
slices in sx IR; JNI's `GetMethodID` expects raw C strings, so
this extracts field 0 when the source is a slice.
Android cross-compile now passes for `examples/ffi-jni-call-02-void.sx`
(2/2 cross targets green). Host run_examples still passes 112/112.
Chess iOS-sim + Android both compile clean.
`collectCaptures` in `src/ir/lower.zig` was the closure free-variable
analyzer that decides which names from a closure body need to be
boxed into the env struct at lambda-build time. Its switch on AST
node kind enumerated every other shape (`.call`, `.if_expr`,
`.match_expr`, `.for_expr`, etc.) but no arm for `.ffi_intrinsic_call`,
so the trailing `else => {}` quietly dropped its `args[]` and
`return_type` walks. Names referenced inside `#objc_call(T)(recv,
"sel:", ...)` from a closure body never made it into the captures
list, so when lowering bound the closure scope from env, those names
came back as "unresolved".
The fix adds the missing arm — walk `return_type` and every `args[i]`
the same way `.call` walks `callee` + `args`.
Companion changes:
- `examples/issue-0038.sx` → `examples/103-ffi-closure-capture.sx`
(out of the open-issue namespace; comment header tightened to
describe the feature, not the historical bug).
- `examples/ffi-objc-call-09-in-construct.sx` drops the
`g_hasher_recv` module-global workaround that was added for this
bug — the closure now captures `recv` from `make_hasher`'s arg
list normally.
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).
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.
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).
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.
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.
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.
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.
Android (toolchain):
--target android / --target android-arm64 → aarch64-linux-android21.
target.zig discovers $ANDROID_NDK_HOME (or scans
~/Library/Android/sdk/ndk/* for the newest), invokes the NDK clang
with -shared -fPIC and links libsxhello.so against -llog -landroid
-lEGL -lGLESv3 -lm -ldl. native_app_glue.c from the NDK is compiled
and linked alongside the sx .o so apps can use the conventional
android_main(struct android_app*) shape; -u ANativeActivity_onCreate
keeps glue's symbol live.
Android (APK):
--apk <out> wraps the .so into a debug-signed installable APK.
target.zig discovers the SDK at $ANDROID_HOME (or
~/Library/Android/sdk), picks the newest build-tools + platforms,
generates a NativeActivity AndroidManifest.xml from --bundle-id,
packages via aapt2 link, appends the lib/ tree, zipalign, then
apksigner against ~/.android/debug.keystore (auto-generated via
keytool on first use). One command end-to-end:
sx build --target android --apk out.apk \\
--bundle-id co.swipelab.foo main.sx
Verified on Pixel 7 Pro: install + launch reaches android_main.
Compiler (entry-point linkage):
Top-level fn defs default to LLVM internal linkage and are lazily
lowered (only `main` was eagerly lowered before). Added
isExportedEntryName() — a small allowlist for names the OS loader
calls: `main`, `android_main`, `ANativeActivity_onCreate`,
`JNI_OnLoad`. These get eagerly lowered AND keep external linkage,
so they actually land in .dynsym.
LSP (imports):
DocumentStore now takes the install-discovered stdlib_paths and
forwards them into resolveImportPath, mirroring the compiler. Before
this, every `#import "modules/..."` resolved through the stdlib path
failed silently inside the LSP and identifiers from those modules
showed as `undefined variable`. Repro on label.sx: 1 false positive
before, 0 after.
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.
Four root causes for "chess UI shows white screen" — all fixed:
1. Hybrid legacy-app + scene-API path on iOS 26. Without
UIApplicationSceneManifest in the Info.plist, iOS 26 booted us in
[rb-legacy] mode and -[UIApplication connectedScenes] returned an
empty set. didFinishLaunching's window-setup code bailed at "no scene"
and the UIWindow never appeared on screen. Fix: emit the manifest in
buildInfoPlist (src/target.zig) AND split the window/view/layer setup
from didFinishLaunching into a new SxSceneDelegate's
scene:willConnectToSession:options: IMP. didFinishLaunching now just
subscribes the keyboard observer and returns YES.
2. UISceneDelegate formal protocol conformance. iOS 26 checks
-[cls conformsToProtocol:@protocol(UISceneDelegate)] before
instantiating the scene delegate; without it the runtime logs
"SxSceneDelegate does not conform to the UISceneDelegate protocol"
and silently uses a default delegate that does nothing. Fix:
look up UISceneDelegate + UIWindowSceneDelegate via objc_getProtocol
and class_addProtocol BEFORE objc_registerClassPair. The protocol
metadata is present at link time (unlike UIApplicationDelegate per
the long-standing legacy note in CHECKPOINT).
3. Protocol method return types via type aliases lowered as void.
The GPU protocol declares `create_shader(...) -> ShaderHandle` where
`ShaderHandle :: u32`. The protocol-decl lowering at lower.zig:7547
passed the return AST node through type_bridge.resolveAstType which
doesn't know about the type_alias_map. resolveTypeName fell through
to its "assume named struct" branch and registered ShaderHandle as
an empty struct ({ }). LLVM IR for the protocol call_indirect then
read `call {} %fn_ptr(...)` — return value discarded; the
subsequent abi.coerce load from a zero-init'd alloca yielded 0.
Symptom: UIRenderer.mtl_shader = 0, set_shader sees state == null,
the render-encoder fires draw with no pipeline state bound, GPU
rejects the command buffer with MTLCommandBufferErrorInternal.
Fix: at the protocol-decl method-type resolution sites in
lower.zig, check type_alias_map BEFORE falling through to
type_bridge.resolveAstType for both params and return type. A
chess-side companion fix in /Users/agra/projects/game/main.sx
(separate commit) memsets the MetalGPU struct after alloc so the
List(*void) fields' len/cap/items aren't garbage.
After all four (this commit + memset companion in chess repo):
- 71/71 regression tests pass.
- Chess game now boots, scene-connects, ticks CADisplayLink, renders
dark-gray clear + UI text + panel dividers every frame on iOS sim.
- Metal-clear example still renders.
Chess board + pieces visual contrast and faint-text-color are remaining
visual-polish items, not compiler/platform-setup issues.
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.
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.