Closes the optional-through-Any gap that test 178 pinned.
Stdlib (`library/modules/std.sx`):
- New `optional_to_string :: (o: $T) -> string` returns `"null"`
when the optional is None, otherwise recurses through
`any_to_string` on the unwrapped inner value. Per-shape
monomorphisation re-emits this for each concrete `?T`.
- `any_to_string` grows a `case optional:` arm that dispatches
through `cast(type) val` (same shape as `case struct:` etc.).
The cast picks up the dynamic optional type from the Any tag.
Compiler (`src/ir/lower.zig`):
- `resolveTypeCategoryTags` recognises "optional" as a dynamic
category, scanning the TypeTable for `info == .optional`. The
type-switch dispatch then routes any ?T tag into the optional
arm.
IR snapshots regenerated where the optional addition shifted
constant pool / string numbering: 142, ffi-objc-call-06,
ffi-objc-dsl-07. 218/218 (test 178 included).
The variadic auto-unwrap in `packVariadicCallArgs` stays in
place — direct `print(opt)` calls still flow through it. The new
arm closes the gap for struct fields, slice elements, and any
other path that boxes an optional before stringifying.
Removes `__block_invoke_void` / `__block_invoke_bool` and their
companion `Into(Block)` impls from
`library/modules/std/objc_block.sx`. The generic
`Into(Block) for Closure(..$args) -> $R` impl from step 5.2 now
covers both shapes (and every other closure shape) via per-mono
`#insert build_block_convert($args, $R)` source emission.
Net stdlib shrinkage: ~52 lines, two trampolines + two per-shape
impls down to zero. Adding a new block-shape consumer no longer
requires touching stdlib — the impl emits per-call-shape on
demand.
`examples/95-objc-block-noop.sx` (zero-arg closure) and
`examples/96-objc-block-multi-arg.sx` (user-declared per-shape
impl for `Closure(s32, *void) -> void`) still pass: 95 routes
through the new generic, 96 keeps its in-file impl as a
documentation example of the user-declares-their-own path.
Suite at 217/217.
Adds the generic `impl Into(Block) for Closure(..$args) -> $R`
in `library/modules/std/objc_block.sx` alongside the existing
hand-rolled `Closure() -> void` and `Closure(bool) -> void`
impls. The convert body is a single
`#insert build_block_convert($args, $R);` — per-call-shape
monomorphisation re-runs the builder so each closure shape gets
its dedicated nested `callconv(.c)` trampoline + Block literal.
The impl-mono path threads pack types through
`pack_bindings[args]` and the single-type return through
`type_bindings[R]`. Both need to be visible to the body's
`$args` / `$R` expression-position references — the existing
lowering only consulted `pack_arg_types` (set by pack-fn mono,
not by tryPackImplMatch). Two small extensions:
- `lowerExpr`'s `.comptime_pack_ref` arm now consults
`pack_arg_types` → `pack_bindings` → `type_bindings` in order,
treating a `type_bindings` hit as a single `const_type(T)`
value rather than the slice form.
- `resolveTypeArg` grows a `.comptime_pack_ref` arm that maps
the same name through `type_bindings` so type-arg positions
(e.g. inside `type_name(...)` in the builder body) resolve
the bound single Type.
- `type_bridge.isTypeShapedAstNode` lists `comptime_pack_ref`
and `pack_index_type_expr` as type-shaped so
`buildTypeBindings`'s strategy-1 explicit-arg path picks
them up when calling a `$T: Type`-generic fn.
`examples/177-generic-into-block.sx` flips green: a
`Closure(s64, s64) -> void` (no hand-rolled impl) is converted
through the generic impl, its block invoked via a typed
`callconv(.c)` fn-pointer, and the closure's side effects land
in the host globals. Hand-rolled impls remain for `()` and
`(bool)` shapes; 5.3 deletes those once a focused test covers
their behaviour through the generic path. Suite at 217/217.
`build_block_convert(args: []Type, $ret: Type) -> string` emits
the convert-body source for the generic `Into(Block) for
Closure(..$args) -> $R` impl (step 5.2):
1. A nested `__invoke :: (block_self: *Block, arg0: T0, ...) ->
R callconv(.c) { ... }` trampoline matching the per-shape
Apple Block ABI.
2. A `return Block.{ ... };` literal whose `invoke` slot points
at the nested trampoline via `xx @__invoke`.
Void-returning shapes emit `typed_fn(block_self.sx_env, args...)`;
non-void emits `return typed_fn(...)`. Per-position arg names
follow `arg0`, `arg1`, ... in declaration order; the typed-fn
cast reconstructs the closure's call signature so the trampoline
hands control back to `sx_fn` with the right argument layout.
`examples/176-build-block-convert.sx` flips green (216/216).
Stdlib:
- `format` / `print` in std.sx — both move from `args: ..Any` to
`..args: []Any`. The post-issue-0049 lowering makes this safe
across module boundaries.
- `open` in fs.sx — `args: ..s32` → `..args: []s32`. Foreign
C-variadic semantics are preserved (the trailing `, ...` lands
in the generated `declare` regardless of which surface form is
used).
Examples:
- `19-varargs.sx` — `sum` / `print_all` migrated.
- `20-any-varargs.sx` — `print_any` / `count` migrated.
- `50-smoke.sx` — `typed_sum` migrated.
- `120-interp-variadic-any.sx` — comment-only update referencing
the new form.
- `ffi-foreign-cvariadic.sx` — three C-variadic foreign decls
migrated; header comment refreshed.
Suite stays at 214/214. The legacy `name: ..T` surface form is
still accepted by the parser; rejection follows in a later commit
once specs.md catches up.
Migrating stdlib's `path_join` to the new variadic syntax
(`(..parts: []string) -> string`) surfaces a latent compiler bug:
`resolveParamType` and `packVariadicCallArgs` treat the new-form
declaration the same as the legacy `parts: ..string` and wrap the
element type in `sliceOf` regardless of whether it already is one.
The new form's `[]string` becomes `[][]string`; the call-site
marshal pack emits `[N x string]` (correct) but the callee stores
its slice param into a `[]([]string)`-typed slot. The shape
mismatch propagates as null/undef Refs that crash
`LLVMBuildExtractValue` inside `emitStrCmp` during emission.
`examples/121-ios-sim-bundle.sx` (existing) and the new focused
`examples/174-new-form-variadic-cross-module.sx` both fail today
with the segfault. The next commit fixes `resolveParamType` +
`packVariadicCallArgs` so both flip green. Stdlib's `format` /
`print` / `open` and the example fixtures stay on the legacy form
in this commit — they migrate in the follow-up cleanup commit.
Reconsidered the M5.A.2 cleanup. The compiler-synthesised trampoline
path was hidden behaviour — a user reading their code couldn't tell
how `xx my_closure : Block` worked without reading lower.zig. That's
exactly the kind of magic sx's design has been pushing against.
New design (strict mode):
1. Stdlib's modules/std/objc_block.sx hand-rolls
`__block_invoke_void` + `Into(Block) for Closure() -> void` and
the same pair for `Closure(bool) -> void` (restored from M5.A.2).
These are readable reference implementations of the bridge ABI.
2. The compiler intercept fires NO synthesis — instead, when
`tryUserConversion` can't find a reachable `Into(Block)` impl for
the closure's signature, it emits a focused diagnostic:
"no `Into(Block) for <Closure-sig>` impl — add a per-signature
`__block_invoke_<sig>` trampoline + Into impl alongside the
existing ones in modules/std/objc_block.sx, or declare it in
your own code"
3. Per-signature declarations live in stdlib (for common signatures)
or in user code (for app-specific ones). 96-objc-block-multi-arg
now demonstrates the user-side pattern in-file — it declares its
own `__block_invoke_void_s32_p` + `Into(Block) for Closure(s32,
*void) -> void` impl alongside its main().
Net effect:
- Every block bridge is source-visible. No hidden compiler magic.
- Users see exactly how the Apple ABI shape is constructed in sx
source — stdlib serves as the reference implementation.
- Compiler enforces the discipline: missing impl → clear diagnostic
pointing at the template.
- Coverage for arbitrary signatures requires conscious user opt-in,
not silent fallthrough.
Removed from lower.zig: `tryClosureToBlockConversion`,
`emitBlockInvokeTrampoline`, `mangleClosureSigForBlock`,
`mangleTypeForBlock`, and the `block_invoke_trampolines` dedup
state field. Net: the synthesis machinery is gone; only the
detection helper `isClosureToBlockCast` remains, used by the
diagnostic.
190/190 example tests pass; chess on iOS-sim green.
The compiler-synthesised trampoline path (previous commit) covers
every closure signature on demand; the hand-rolled stdlib impls
were only for two specific shapes (`Closure() -> void`,
`Closure(bool) -> void`) and are now strictly redundant.
Kept: the `Block` struct, `BlockDescriptor`, the
`_NSConcreteStackBlock` extern decl, and the shared
`__sx_block_descriptor` global. The compiler-emitted code
references all four; users still need to `#import
"modules/std/objc_block.sx";` to bring them into the module.
Removed: `__block_invoke_void`, `__block_invoke_bool`, and both
`impl Into(Block) for Closure(...) -> void` blocks. Replaced with
a comment block explaining how the compiler now handles the cast.
After this commit, `xx my_closure : Block` works for ANY closure
signature with no per-signature stdlib boilerplate. 189/189
example tests pass; chess on iOS-sim green.
Three pieces, no behavior change yet:
1. `ObjcPropertyKind` enum (strong/weak/copy/assign) + `objcPropertyKind`
helper in lower.zig. Reads `field.property_modifiers`, applies the
default rule (`*<ObjC-class>` → strong; primitives → assign), and
emits loud diagnostics for the silent-error budget:
- unknown modifier name (typo) → "expected one of: strong, weak, copy, ..."
- conflicting modifiers (e.g. `strong,weak`) → "mutually exclusive"
- `weak` on non-object slot → "requires a pointer-to-Obj-C-class type"
- `copy` on non-object slot → same
- `strong` (default or explicit) on `*void` → "ambiguous: specify
#property(strong|weak|copy|assign) explicitly"
Called from `emitObjcDefinedClassPropertyImps` for validation; the
returned kind isn't wired into setter/getter/dealloc yet — that's
the next three commits.
2. `ensureArcRuntimeDecls` lazily declares libobjc's ARC helpers:
objc_retain, objc_release, objc_storeWeak, objc_loadWeakRetained,
objc_initWeak, objc_destroyWeak. Uses the existing
`ensureCRuntimeDecl` pattern; idempotent.
3. Fix existing NSObject method names in std/objc.sx — `isEqual_`,
`isKindOfClass_`, `respondsToSelector_` had trailing underscores
that the selector mangling turned into double-colon selectors
(`isEqual::`). Removed the trailing underscore so the selectors
come out as `isEqual:`, `isKindOfClass:`, `respondsToSelector:`
as Apple's runtime expects.
4. Two xfail regression tests:
- ffi-objc-arc-02-strong-property: assigns child to parent's strong
property, releases the original child reference. Midpoint check:
child's dealloc should NOT have fired (strong setter retained).
Pre-M4.B-setter: child dealloc fires immediately → "FAIL: child
dealloc'd at midpoint" snapshot. Exit code 1.
- ffi-objc-arc-03-weak-property: assigns target to holder's weak
property, releases target. Reads holder.target → should be null
(auto-niled). Pre-M4.B-getter/setter: reads stale pointer →
"FAIL: weak property didn't auto-nil" snapshot.
These will turn green as M4.B setter (commit 2), getter (commit 3),
and dealloc-cleanup (commit 4) land. Each subsequent commit updates
the snapshot to reflect the now-passing output.
189/189 example tests pass; chess on iOS-sim green.
Declare `NSObject` in std/objc.sx as `#foreign #objc_class("NSObject")`
with the canonical instance + class-method surface every Obj-C class
inherits: `retain`/`release`/`autorelease`/`new`/`alloc`/`init`/
`description`/`hash`/`isEqual_`/`isKindOfClass_`/`respondsToSelector_`/
`class`. Root the foreign-class hierarchy in uikit.sx at NSObject by
adding `#extends NSObject;` to every previously-unrooted declaration
(NSValue, NSNumber, NSDictionary, NSSet, NSNotification, NSBundle,
NSNotificationCenter, NSRunLoop, CADisplayLink, CALayer, EAGLContext,
UIScreen, UIResponder) plus deeper chain fixes (NSMutableDictionary
extends NSDictionary; UIWindow extends UIView; UIViewController
extends UIResponder). After this, M2.3's extends-chain walk finds
`retain`/`release` on any UIKit-typed value:
view := UIView.alloc().init();
defer view.release(); // canonical sx idiom — no language magic
Plus `autoreleasepool(body: Closure())` stdlib helper that wraps
`body` in `objc_autoreleasePoolPush` / `defer objc_autoreleasePoolPop`.
Required for Foundation factory returns; closure-call frame is real
cost so hot loops should inline the push/defer-pop pattern manually.
Smoke test `ffi-objc-arc-01-autoreleasepool.sx` exercises both
patterns; refresh of two IR snapshots picks up the new stdlib decls
appearing in test outputs that include `modules/std/objc.sx`.
185/185 example tests pass; chess on iOS-sim green.
Three threads, one commit because they're entangled:
1. Helper free functions on `*UIKitPlatform` (refresh_safe_insets,
read_screen_scale, create_gl_context, setup_renderbuffer,
present_renderbuffer, compute_layer_pixel_size) → methods on the
`impl Platform for UIKitPlatform` block. IMP-shape trampolines
(`uikit_keyboard_will_change_frame`, `uikit_scene_will_connect[_ios]`,
`uikit_gl_view_tick/layout/touches_*`, `uikit_subscribe_keyboard_notifications`)
also collapse into methods on UIKitPlatform — the
`(self: *void, _cmd: *void, ...)` form is no longer needed since
M3 made the #objc_class trampolines compiler-synthesized. Class
method bodies in SxAppDelegate / SxSceneDelegate / SxGLView /
SxMetalView now read `if g_uikit_plat == null { return; }
g_uikit_plat.x();` — no more `xx self, xx 0` casts at every IMP
call site.
2. Declarative `layerClass` form. SxGLView and SxMetalView promote
from the M2.1(a) constant-with-runtime-string-lookup workaround
(`layerClass :: *void = objc_getClass("CAEAGLLayer".ptr);`) to
the class-method expression-body form
(`layerClass :: () => CAEAGLLayer.class();`). Type stays `*void`
until M1.1.b lands `Class(T)` parameterisation; the value side
already matches the plan. Backing this: foreign-class declarations
for CAEAGLLayer (extended with `class :: () -> *void;`) and a new
CAMetalLayer foreign-class declaration alongside it. Both
`#extends CALayer` so the dispatch chain knows about the parent.
3. Optional-shape idiom pass on uikit.sx. `xx`-as-optional-wrap on
field assignments (`plat.gl_ctx = xx ctx`, `plat.text_field = xx tf`,
`plat.display_link = xx link`) dropped — implicit `T → ?T` does
the right thing. `!` force-unwraps replaced with `if val := opt
{ ... }` safe-narrowing (the keyboard handler, the GL-context
read in setup/present renderbuffer, the gl_view read in scene
bootstrap). `orelse` (Zig keyword) that briefly snuck into the
keyboard handler removed in favour of the `if win := plat.window`
narrowing pattern. Result: no `xx` casts left on the implicit
T→?T path; all optional access goes through `if val :=`.
IR snapshots `ffi-objc-call-06-sret-return.ir` and
`ffi-objc-dsl-07-mangling-table.ir` refresh to pick up the new
`object_getIvar` / `object_setIvar` runtime-helper declarations
introduced when M1.2 A.3 made instance-method bodies route field
access through the state ivar.
Chess on iOS-sim green throughout. 184/184 example tests pass.
The UIKitPlatform struct had a string of '*void = null; // UIWindow*'
fields — the type lived in a comment, every callsite had to 'xx'-cast
back to the real type. Migrated to the real foreign-class pointer
types now that M3 declared all the relevant '#objc_class' aliases:
window: ?*UIWindow
root_vc: ?*UIViewController
gl_view: ?*UIView (SxGLView OR SxMetalView — both extend UIView)
gl_layer: ?*CALayer (CAEAGLLayer OR CAMetalLayer)
gl_ctx: ?*EAGLContext
display_link: ?*CADisplayLink
Each field is wrapped in '?' since the platform may not have set
it yet (gl_ctx is null in metal mode, display_link is null before
the first frame, etc.).
SxSceneDelegate's window getter/setter now take/return '?*UIWindow'
instead of '*void' so calling code doesn't need an xx-cast.
Required fix in objcTypeEncodingFromSignature: '?T' (optional) was
bailing with 'type kind not yet supported'. Apple's runtime treats
nullability as 'pointer may be null' — the wire encoding is the
same as T. Recursive unwrap handles ?*UIView → '@', ?*CADisplayLink
→ '@', etc.
Chess on iOS-sim: board renders, full pipeline intact. 183 tests
+ zig build test green.
Three holdover free functions from the pre-M3 era were each
just two or three lines that forwarded to a global. With M3
finished, every call site is one #objc_class method body, so
the wrapper indirection earns nothing — inline them.
Deleted:
uikit_window_getter → body of SxSceneDelegate.window
uikit_window_setter → body of SxSceneDelegate.setWindow
uikit_did_finish_launching → body of
SxAppDelegate.application_didFinishLaunchingWithOptions
The bigger helpers (uikit_keyboard_will_change_frame,
uikit_scene_will_connect, uikit_gl_view_tick/_layout, the four
uikit_gl_view_touches_*) stay — their bodies are 30-80 lines
each, so wrapping them in a small forwarding method body inside
#objc_class is the cleaner factoring.
Chess on iOS-sim: board renders, full game state intact. 183
example tests + zig build test green.
Three slices in one commit since they're tightly coupled (the
M3.5 deletion only makes sense after M3.3 and M3.4):
M3.3 — SxGLView migrated to declarative '#objc_class("SxGLView")':
- '#extends UIView' for the view-hierarchy + responder chain.
- 'layerClass :: *void = objc_getClass("CAEAGLLayer".ptr);' uses
the M2.1(a) class-level constant form. Registered on the
metaclass; UIView's +layerClass override dispatches here so
EAGL gets the right backing layer.
- Six instance methods (sxTick, layoutSubviews, four touch
selectors) forward to existing legacy IMP free functions.
M3.4 — SxMetalView migrated, same shape as SxGLView; differs only
in the 'layerClass' constant returning CAMetalLayer instead of
CAEAGLLayer. The five shared IMPs (sxTick/layoutSubviews/4 touch
handlers) reach the same free functions — they already branch on
plat.gpu_mode for GL-specific renderbuffer code.
M3.5 — uikit_register_classes() and the two helper registration
functions are deleted outright. Every sx-defined Obj-C class in
this module now goes through the compiler's M1.2 / M2.1(a)
synthesis path at module init. The call site inside
UIKitPlatform.init is gone too — just a comment marking the
migration point.
Chess on iOS-sim: board renders, scene-delegate connection still
fires, GL/Metal layer setup intact, touch dispatch routes through
the synthesized IMP trampolines. 183 example tests + zig build
test green.
End of M3. The platform layer's Obj-C-runtime wiring is fully
declarative.
Remaining: M4 (autoreleasepool + ARC ops), M5 (closure↔block),
M6 (auto-import + production hardening). M1.1.b (Class(T)
parameterization + instancetype) is still deferred — none of
the migrated uikit code needed it.
Migrates SxSceneDelegate from the hand-rolled
objc_allocateClassPair + class_addMethod + class_addProtocol
sequence to the declarative form:
SxSceneDelegate :: #objc_class("SxSceneDelegate") {
#extends UIResponder;
#implements UISceneDelegate;
#implements UIWindowSceneDelegate;
scene_willConnectToSession_options :: (self, scene, session, options) { ... }
window :: (self) -> *void { ... }
setWindow :: (self, w) { ... }
}
emit_llvm now honors '#implements' in the class-pair init
constructor — for each #implements ProtocolAlias on the cache
entry's AST, emit before objc_registerClassPair:
proto = objc_getProtocol("ProtocolName")
class_addProtocol(cls, proto)
iOS checks 'class_conformsToProtocol' when instantiating scene
delegates; without the conformance the runtime silently rejects
the class and a default scene with no delegate gets created
instead. The protocol-getter returns null on dead-strip /
runtime mismatch (rare but possible) — the runtime treats
class_addProtocol(cls, null) as a no-op, so no explicit null
check needed.
Method bodies forward to the existing legacy free IMP functions
(uikit_scene_will_connect, uikit_window_getter,
uikit_window_setter) so we don't have to inline the scene-
connect setup logic (~80 lines).
uikit_register_classes is now tiny — just the two remaining
view-class helpers (M3.3 SxGLView + M3.4 SxMetalView). M3.5
deletes the function entirely once those land.
Chess on iOS-sim: board renders, scene delegate fires, touch
events route correctly. 183 example tests + zig build test
green.
Two coupled changes that unblock the uikit_register_classes
migration:
1) M1.2 A.3 — body's 'self' is the Obj-C id (opaque), NOT the
state struct. Matches Apple's ObjC semantics where 'self' IS
the object. Cocoa idiom 'xx self → id' works at runtime calls
(addObserver:, etc.); previously the trampoline replaced
'self' with the state-struct pointer, breaking any runtime
call that expected an id.
'*Self' substitution in resolveTypeWithBindings now points at
foreignClassStructType(fcd) — the opaque class stub — instead
of objcDefinedStateStructType(fcd).
'self.field' access on a sx-defined class instance field is
rewritten by lowerFieldAccess to go through the __sx_state
ivar:
state = object_getIvar(self, load(__<Cls>_state_ivar))
val = struct_gep(state, field_idx) → load
Both read (lowerFieldAccess) and write (lowerAssignment) take
this path. Compound ops (+=, -=, etc.) are supported via
storeOrCompound. The lookup is filtered: skip property fields
(those still go through the M2.2 msgSend getter/setter
dispatch) and foreign classes (no state).
New helpers in lower.zig:
- lookupObjcDefinedStateFieldOnPointer — match check.
- lowerObjcDefinedStateForObj — emit the object_getIvar +
ivar-global-load idiom (shared between read + write paths).
- lowerObjcDefinedStateFieldRead — the load path.
Also moved the @llvm.global_ctors registration out of the
sx-defined class-pair init constructor — global_ctors fires
DURING dyld's framework load, before UIKit registers its Obj-C
classes. objc_getClass("UIResponder") returned null, super
was null, objc_registerClassPair crashed. main's entry block
is post-framework-load but pre-user-code — exactly the right
window. New helper injectCtorIntoMain.
2) M3.1 — SxAppDelegate migrated to declarative #objc_class.
uikit_register_classes' hand-rolled objc_allocateClassPair +
class_addMethod for SxAppDelegate is gone; the compiler
synthesises the class at module init. The method bodies
forward to the existing legacy IMP free functions
(uikit_did_finish_launching, uikit_keyboard_will_change_frame)
so we don't have to inline 70+ lines of keyboard-frame logic
right now.
Also adds UIResponder foreign-class declaration and chains
UIView / UITextField to it via #extends UIResponder so the
methods that previously lived on UITextField directly
(becomeFirstResponder etc.) move to their proper home.
Chess on iOS-sim: board renders, full state intact. 183 example
tests + zig build test green.
For every sx-defined #objc_class, emit a C-callconv +alloc IMP
that the Obj-C runtime calls when '[Cls alloc]' fires (from sx
code, UIKit instantiation, Info.plist principal class, etc.):
+alloc IMP (cls: Class, _cmd: SEL) -> id
instance = class_createInstance(cls, 0)
state = malloc(STATE_SIZE)
memset(state, 0, STATE_SIZE)
object_setIvar(instance, load(@__<Cls>_state_ivar), state)
return instance
STATE_SIZE = max(typeSizeBytes(state struct), 1) — always at
least one byte so the ivar is never null after +alloc returns.
The IMP is registered on the METACLASS (class methods live there
— every Class object's isa points to the metaclass) in emit_llvm's
class-pair init constructor:
metaclass = object_getClass(cls)
sel_alloc = sel_registerName("alloc")
class_addMethod(metaclass, sel_alloc, alloc_imp, "@@:")
That override wins over NSObject's default +alloc; runtime
instantiations get the __sx_state ivar bound automatically.
Per-instance allocator binding (the plan's full design — store
the Allocator value in the state struct so -dealloc frees through
the same one) is deferred. libc malloc/free is fine for v1; we'll
upgrade once Month 4's autoreleasepool + ARC ops shake out.
REFACTOR: collapsed five duplicate 'get<Name>Fid' helpers and
their cache fields (object_getIvar, object_setIvar,
class_createInstance, malloc, memset) into a single
'ensureCRuntimeDecl(name, params, ret) -> FuncId'. The helper
checks for an existing decl by name first (avoids the
'class_createInstance.1' duplicate-symbol crash when stdlib's
'#foreign' decl is already in the module). One helper instead
of one-per-function = ~150 lines deleted.
object_getIvar / object_setIvar added to stdlib std/objc.sx
so user code can use them too (146 exercises object_getIvar
to verify __sx_state was bound to a non-null state pointer
after +alloc).
146-objc-class-alloc-roundtrip.sx end-to-end against macOS:
'[SxFoo alloc]' returns non-null AND object_getIvar(instance,
__sx_state) returns the state ptr. Real Obj-C runtime, no
mocks.
175 example tests pass (+1). zig build test green.
Adds named stand-ins for the three opaque Obj-C runtime types
and Apple's signed-char boolean to library/modules/std/objc.sx:
id :: *void; // any Obj-C instance pointer
Class :: *void; // a class object pointer
SEL :: *void; // a registered selector
BOOL :: s8; // Apple's signed-char boolean (NOT sx's bool)
All resolve to their underlying type at the LLVM layer — no
runtime cost — but make foreign-class declarations read closer
to Objective-C source. The header's old caveat about lacking
type aliases is gone.
141-objc-type-aliases.sx exercises the aliases against the real
macOS Obj-C runtime: alloc/init an NSObject, fetch its class
via objc_getClass, sel_registerName a SEL, then call
'isKindOfClass:' returning BOOL=1. Non-macOS paths print the
same line to keep the snapshot stable.
DEFERRED (M1.1.b, follow-up): 'Class(T)' parameterization with
#extends-aware covariance, and 'instancetype' per-decl
substitution. Both require compiler-level type-check support
beyond plain stdlib aliases.
170 examples pass (+1).
Third cluster: NSRunLoop and CADisplayLink move to declarative
`#objc_class` blocks.
Classes declared:
- NSRunLoop → currentRunLoop (class)
- CADisplayLink → displayLinkWithTarget_selector (class),
addToRunLoop_forMode (instance),
targetTimestamp (instance), duration (instance)
The display-link instance is created with the new typed call shape:
link := CADisplayLink.displayLinkWithTarget_selector(plat.gl_view, sel_tick);
plat.display_link = xx link; // keep the *void slot in the
// platform struct for ABI parity
runloop := NSRunLoop.currentRunLoop();
link.addToRunLoop_forMode(runloop, mode_ns);
The `sxTick:` callback's `link: *void` param is cast to
`*CADisplayLink` at function entry so the body's `link.duration()` /
`link.targetTimestamp()` calls type-check.
Two now-redundant `objc_getClass(...)` lookups for CADisplayLink /
NSRunLoop are gone — the class slots come from the declarative
declarations via `__sx_objc_class_init`.
166/166 tests; chess builds clean on macOS / iOS / Android.
Second cluster: NSNotification, NSBundle, NSNotificationCenter move
from `#objc_call(T)(recv, "sel:", args)` to declarative
`#foreign #objc_class("Cls") { ... }` blocks.
Classes declared (alongside the C1 Foundation utility group):
- NSNotification → userInfo (instance, returns *NSDictionary)
- NSBundle → mainBundle (class), resourcePath (instance)
- NSNotificationCenter → defaultCenter (class),
addObserver_selector_name_object (instance)
The 4-keyword `addObserver:selector:name:object:` selector derives
from the underscore-separated sx name via the default mangling rule
— no `#selector` override needed.
Cleanup wins:
- `objc_getClass("NSBundle")` and `objc_getClass("NSNotificationCenter")`
call sites gone — class slots now populated by emit_llvm's
`__sx_objc_class_init` constructor.
- `userInfo`'s return type is `*NSDictionary` directly, so the
previous `*void → *NSDictionary` cast at the keyboard-frame
callsite collapses.
166/166 tests; chess builds clean on macOS + iOS + Android via
`sx build main.sx`.
First of five Phase-3.2 migration clusters. Foundation utility
classes (NSValue, NSNumber, NSDictionary, NSMutableDictionary, NSSet)
in `library/modules/platform/uikit.sx` move from the explicit
`#objc_call(T)(recv, "selector:", args)` form to declarative
`#foreign #objc_class("Cls") { ... }` blocks with `recv.method(args)`
dispatch.
Classes declared (all near the top of uikit.sx, after the CGRect
struct):
- NSValue → CGRectValue (instance)
- NSNumber → numberWithBool (class), doubleValue +
unsignedLongValue (instance)
- NSDictionary → objectForKey (instance)
- NSMutableDictionary → dictionary (class), setObject_forKey (instance)
- NSSet → anyObject (instance)
Each call site casts the `*void` receiver to the typed foreign-class
pointer before dispatch — the existing `*void` shape is preserved
in the struct fields and outer parameter types; only the dispatch-
local copy is typed. This keeps the diff scoped to call-site
rewrites without rippling type changes through every consumer.
The four `objc_getClass(...)` calls that previously resolved
NSMutableDictionary / NSNumber at runtime are gone — Phase 3.1's
`__sx_objc_class_init` constructor populates the cached class slot
for each declared `#objc_class` at module load via
`OBJC_CLASSLIST_REFERENCES_<Cls>`.
166/166 example tests; chess clean on macOS + Android via
`tools/verify-step.sh` (iOS sim skipped — no booted simulator in
this run; previous full run was green at HEAD~6).
`static name :: ...` was redundant — instance methods always declare
`self: *Self` as their first param by convention. The parser now derives
`is_static` from the first param's TYPE: if it's `*Self` the method is
an instance method; anything else (including no params at all) is a
class method. Removes a token from the surface, keeps the dispatch
behavior identical.
The receiver param's NAME doesn't matter — only its type. Calling the
first param `this`, `me`, `receiver`, etc. is fine as long as the type
is `*Self`. This mirrors how the rest of sx handles receiver dispatch.
Migration of every site that used the keyword:
- `library/modules/platform/android.sx` — `SurfaceView.new(ctx)`.
- `examples/ffi-jni-class-03-static.sx` — `Math.abs(n)`.
- `examples/ffi-jni-main-03-ctor.sx` — `SurfaceView.new(ctx)` in the
`#jni_main` body.
- `examples/ffi-objc-dsl-05-static.sx` — NSObject's `.class()` /
`.description()`.
164/164 example tests; chess clean on macOS / iOS sim / Android via
`tools/verify-step.sh`.
Building on the Option 3 lvalue-borrow rule, the long-lived allocators
in `library/modules/allocators.sx` (GPA, Arena, TrackingAllocator) now
return their state by value instead of via a heap-allocated `*T`. The
caller binds the result to a local; the local IS the allocator state.
`xx local` borrows that storage under Option 3, so the `Allocator`
protocol value's `ctx` points at the local — no heap allocation for
the state struct, no `free` of the state needed.
```sx
gpa := GPA.init(); // GPA (value)
arena := Arena.init(xx gpa, 4096); // Arena (value)
tracker := TrackingAllocator.init(xx gpa); // TrackingAllocator (value)
push Context.{ allocator = xx tracker, data = null } { ... }
```
Why by-value:
- One fewer `libc_malloc` per allocator instance.
- No state-struct leak. The local is reclaimed at scope exit; `deinit`
only handles downstream resources (chunks, etc.) — not its own struct.
- Owning structs can embed allocators as value fields directly.
Callsite changes:
- `library/modules/ui/pipeline.sx`: `arena_a: Arena;` / `arena_b:
Arena;` (was `*Arena;`). The `build_arena: *Arena` local takes
`@self.arena_a` / `@self.arena_b`.
- `examples/126-xx-recover-then-dispatch.sx`: `recovered == @gpa`
instead of `recovered == gpa` (gpa is a value now).
- `examples/135-xx-lvalue-borrows.sx`: drop the `tracker_ptr.*`
deref — `init` already returns the value.
- `examples/50-smoke.sx`: Arena alloc counts dropped by 1 (no
state-struct allocation). Comments + snapshot updated.
`Arena.deinit` drops the trailing `parent.dealloc(xx a)` — the
caller's local owns the storage.
FFI IR snapshots regenerated to reflect the new signatures:
`@GPA.init` returns `i64` (was `ptr`); `@Arena.init` and
`@TrackingAllocator.init` use sret returns (was `ptr`).
CLAUDE.md "Allocator construction" rule rewritten around the
by-value convention. The forbidden caller-provides-storage and
redundant-pointer-rename patterns are still forbidden but for the
right reasons now (verbose, fragile) rather than as a workaround
for the old `init() -> *T` shape.
157/157 example tests pass; chess clean on macOS, iOS sim, and
Android via `tools/verify-step.sh`.
The chess panel-text regression (text vanished after the first move on
macOS) had a single root cause: GlyphCache's entries List, hash table,
and shaped_buf grew through `context.allocator` — which during render
is the per-frame arena. On the next arena reset the backing died, and
subsequent glyph lookups read garbage / wrote into freshly-allocated
view-tree memory.
Fix is shaped as the user proposed: `List(T)`'s mutations take an
optional trailing `alloc: Allocator = context.allocator` argument. No
allocator stored on the container, no init ceremony, every existing
`list.append(item)` callsite keeps working unchanged. Long-lived
owners now write `list.append(item, self.parent_allocator)` and the
arena-leak bug becomes impossible to write accidentally.
Default-arg substitution previously only fired for identifier callees
(`expandCallDefaults` at lower.zig:7978). Extended to the generic
struct-method dispatch path (`list.append(...)` lands here) via a new
`appendDefaultArgs` helper that lowers fd.params[i].default_expr in
the caller's scope and appends to the lowered args slice.
Long-lived owners updated to capture `parent_allocator: Allocator` at
init and use it for every internal growth:
- GlyphCache (the chess bug) — entries, shaped_buf, hash_keys,
hash_vals, atlas bitmap.
- DockInteraction — drops the existing `push Context` workaround in
`ensure_capacity` for the explicit-arg form.
- StateStore — entries list + per-entry data buffer.
- Gles3Gpu, MetalGPU — shaders, buffers, textures (atlas-grow during
render would otherwise leak resources into the frame arena).
Also kept: an operator-precedence fix in pipeline.sx
(`(self.frame_index & 1) == 0` instead of
`self.frame_index & 1 == 0`, which parses as
`self.frame_index & (1 == 0)` = always 0). That was a stealth
single-arena-only bug that masked the GlyphCache one for a long time.
Docs:
- specs.md §11 documents `param: T = expr` default parameter values.
The parser already supported it — formalised in the spec now.
- current/CHECKPOINT-MEM.md logs the change.
- CLAUDE.md REJECTED PATTERNS gains a "Long-lived containers growing
through context.allocator" section with the `parent_allocator`
capture template and the list of existing examples to mirror.
155/155 example tests pass — zero-diff against snapshots since every
existing callsite still resolves to `context.allocator`.
Passing a default-conv sx function to a `callconv(.c)` fn-pointer slot
(e.g. pthread_create's start routine) used to silently mismatch ABIs:
the C-side caller didn't supply __sx_ctx, so the sx-side body read its
first user param as garbage. The bug surfaced as a SIGSEGV inside
ANativeWindow_setBuffersGeometry on Android during chess bringup.
Now the compiler rejects the coercion outright at the bare-fn name
lookup site:
error: call-convention mismatch: 'sx_handler' is declared with
default sx convention but the target type expects callconv(.c)
Also: `#foreign` declarations without an explicit `callconv` now default
to `.c` instead of `.default`. Every external C symbol is by definition
C-conv; the previous default silently typed `objc_msgSend` (et al.) as
default-conv, so the check would fire on the consumer side when the
user typed a fn-ptr as `callconv(.c)`. With the foreign-default fix,
the existing typed-msgSend casts in `std/objc.sx` and `gpu/metal.sx`
keep type-checking and the rule is "C-conv on both sides or neither."
Caught by the new check (fixed in the same commit):
- `ios_gl_proc` in `platform/uikit.sx` lacked callconv(.c) but was
passed to `load_gl` whose `get_proc` slot expects it.
- `ffi_apply_callback` / `ffi_apply_callback2` in
`examples/ffi-06-callback.sx` had default-conv fn-ptr params but
the C bodies (in the companion .c) are unambiguously C-conv.
Regression test: `examples/131-callconv-mismatch-diagnostic.sx`
locks in the diagnostic shape (sx-conv fn → callconv(.c) slot).
153/153 example tests pass. Chess green on macOS / iOS sim / Android.
Verify-step uncovered three categories of regressions where sx code
calls into the platform's C ABI through fn-pointer types or as a
registered callback. Every site now declares the right convention.
C-side calls INTO sx → callconv(.c) on the sx function:
- platform/android.sx: sx_android_render_thread_entry is the start
routine pthread_create invokes — pthread treats it as a C function.
Also annotate the pthread_create signature so the start-routine fn-
pointer field rejects mismatching sx fns at compile time.
sx code calling typed fn-pointers cast from C symbols → callconv(.c)
on the fn-pointer type:
- opengl.sx: 55 GL fn-ptr globals + load_gl's proc-loader param. GL
trampolines are macOS/iOS/Android system code.
- std/objc.sx: the two typed `objc_msgSend` casts.
- gpu/metal.sx: ~40 typed `objc_msgSend` casts across Metal command
encoder / device / pipeline construction.
The block invoke trampolines (objc_block.sx) call back INTO sx (the
closure trampoline). The typed fn-ptr there stays default-conv so
ctx prepends correctly. Compiler change: a callconv(.c) sx function
now binds `current_ctx_ref` to `&__sx_default_context` at entry (used
to be gated by `isExportedEntryName`). C-callable sx callbacks like
the block invokes don't get their own __sx_ctx param but their bodies
still need a real Context to forward to the closure they delegate to.
Tests: 152/152 example suite + chess green on all 3 platforms.
Screenshots at /tmp/sx-game-{macos,iossim,android}.png.
The `context : Context = ---;` global in `library/modules/std.sx` had
no remaining readers — all `context.X` lookups in user code resolve
through `current_ctx_ref` (Step 5), `push Context.{...}` uses an alloca
slot (Step 6), and `allocViaContext` sources from the lowering's
current ref. `emitDefaultContextInit` (the only writer) was already
removed in Step 5.
`inferExprType` for the `context` identifier now returns the registered
`Context` type when implicit-ctx is enabled, mirroring the lowering's
identifier-handling fast path. Without this, `context.allocator` would
type as `s64` (the fallback) and the field access would fail.
11 JNI/ObjC IR snapshots regen — the `@context` LLVM global is gone
from each.
152/152 example tests pass.
The session-long set of changes that lay the groundwork for the
Jai-literal implicit-Context-parameter refactor. Lots of accumulated
work; the new arrival is the implicit-ctx foundation (steps 1+2 of
the plan in current/CHECKPOINT-MEM.md):
Step 1 — `CAllocator :: struct {}` stateless allocator in
library/modules/allocators.sx, delegating directly to
libc_malloc/libc_free. `ConstantValue` in src/ir/inst.zig gains a
`func_ref: FuncId` leaf so nested aggregates can carry function
pointers (the inline Allocator value's fn-ptr fields). Switch
sites updated in emit_llvm.zig, print.zig, interp.zig.
Step 2 — `emitDefaultContextGlobal` in src/ir/lower.zig synthesises
a static `__sx_default_context` global with a nested-aggregate
init_val pointing at the CAllocator → Allocator thunks. The
second-pass `initVtableGlobals` in emit_llvm.zig is generalised
to handle `.aggregate` init_vals (re-emits after func_map is
populated so func_ref leaves resolve to real symbols).
Also folded in from earlier work this session:
- Phase 1.1: `xx value` heap-copy in `buildProtocolValue` routes
through `context.allocator` via the new `allocViaContext` helper.
- interp.zig: `marshalForeignArg` double-offset bug fixed —
`heapSlice` already adds `hp.offset` to the slice ptr, so the
extra `+ hp.offset` was scribbling memcpy/memset into adjacent
heap state, corrupting `heap.items[0]`. Symptom: `build_format`
at comptime produced zero bytes, all `print` calls failed.
- Lazy lowering: `lazyLowerFunction` now declares foreign-body
functions as extern stubs in the local (comptime) module so
cross-module foreign calls resolve.
- Allocator API: all stdlib allocators on one-line `init() -> *T`
(CAllocator/GPA: libc-backed; Arena/TrackingAllocator: parent-
backed; BufAlloc: embeds state at head of user buffer).
- issues 0038 (transitive #import), 0039 (chess + stdlib migration
fallout), 0040 (generic struct method dot-dispatch), 0041
(pointer types as type-arg), 0042 (alias name resolution) — all
fixed; regression tests in examples/.
- Diagnostic: `emitError` now embeds the lowering's
`current_source_file` and enclosing function in the literal
message; SX_TRACE_UNRESOLVED=1 dumps a Zig stack trace at the
emit site so misattributed spans can't hide where the failure
is.
- tools/verify-step.sh (all-platforms gate) and tools/scratch.sh
(interp/codegen parity tester) added.
Test suite: 152 example tests pass; chess builds + screenshots on
macOS / iOS sim / Android.
Week 7 of /Users/agra/.claude/plans/lets-plan-to-move-splendid-pumpkin.md
plus the android.sx refactor + three sx-compiler fixes hit along the way
to get chess on Pixel 7 Pro responding to touch end-to-end.
library/modules/platform/bundle.sx now covers the Android APK shape
alongside macOS / iOS-sim / iOS-device. `android_bundle_main` discovers
the SDK ($ANDROID_HOME / $ANDROID_SDK_ROOT / $HOME/Library/Android/sdk),
picks the highest-versioned build-tools + platforms via
`process.run("ls .. | sort -V | tail -1")`, stages
`<apk>.stage/lib/arm64-v8a/<libfoo.so>`, synthesizes
AndroidManifest.xml (NativeActivity vs `#jni_main` Activity branch),
writes each `#jni_main` decl's Java source under
`<stage>/java/<pkg>/<Cls>.java`, runs javac --release 11 + d8 to
produce classes.dex, aapt2-links the unaligned APK, appends lib/ +
classes.dex + each registered asset tree via zip, zipalign + ensure
debug keystore via keytool + apksigner sign.
Compiler-side accessors (src/ir/compiler_hooks.zig + library/modules/compiler.sx):
- is_android predicate.
- set_manifest_path / manifest_path + set_keystore_path / keystore_path.
- jni_main_count / jni_main_foreign_path_at(i) /
jni_main_java_source_at(i) surface the `#jni_main` emissions that
the Zig createApk previously consumed directly.
- main.zig wires manifest_path, keystore_path, and the per-decl
(foreign_path, java_source) parallel slices into BuildConfig before
invoking the post-link callback.
CLI `--apk <path>` keeps working as a transitional alias: it now feeds
bundle_path so the existing auto-`post_link_module = "platform.bundle"`
shim fires the same way as `--bundle`. main.zig no longer calls
target.createApk directly.
Deletions in src/target.zig: createApk, compileJniMainSources,
buildJniMainManifest, buildAndroidManifest, ensureDebugKeystore,
libNameFromSoBasename, plus helpers splitForeignPath / discoverJavac /
discoverAndroidSdk / findHighestSubdir / runProcess / runProcessIn
(~400 lines). git grep returns only the obituary comment.
library/modules/platform/android.sx refactor (chess Android dependency):
- Module-level globals retired (g_app_window, g_egl_*, g_viewport_*,
g_dpi_scale, g_should_stop, g_render_thread*, g_user_main_fn,
g_touch_*) → AndroidPlatform struct fields.
- All sx_android_* helpers take `plat: *AndroidPlatform` as first arg.
Render thread receives plat via pthread_create's arg.
- New `logical_w: f32 = 0.0` field. Consumers set it before init() to
define the design width in points; `recompute_scale` derives
`dpi_scale = pixel_w / logical_w` (or 1.0 if unset). Called on
init / set_viewport / egl_init. drain_touches divides incoming
physical pixel coords by dpi_scale so chess sees logical-space
positions matching its layout. Touch lands on the right squares.
Three sx-compiler bugs hit + fixed along the way:
1. Top-level `inline if OS == .X { decls }` body decls were silently
dropped because scanDecls/lowerDecls had no .if_expr arm. New
`flattenComptimeConditionals` pre-pass in src/imports.zig
(threaded via ComptimeContext from core.zig) hoists matching arms
recursively. Regression at examples/124-inline-if-hoist-toplevel.sx.
2. Parser rejected `#import` / `#framework` inside inline-if bodies
because parseStmt in src/parser.zig only had arms for `#insert`.
Added the missing arms. Regression at
examples/123-inline-if-import-in-body.sx (landed earlier).
3. JNI `Call<T>Method` switches in src/ir/emit_llvm.zig (instance /
nonvirtual / static) were missing `.f32` rows — jfloat returns
(e.g. MotionEvent.getX/getY) fell into the silent-undef else arm.
Chess's sx_android_push_touch(plat, getAction(), getX(), getY())
delivered garbage f32 coords to the touch ring, so taps landed
nowhere recognisable. Added `.f32 => Jni.Call{Static,Nonvirtual,}FloatMethod`
rows to all three switches; lifted unsupported-type detection
from emit_llvm into lowerForeignMethodCall with proper
source-spanned diagnostics (`isJniReturnTypeSupported`). Regressions
at examples/ffi-jni-call-10-jfloat-return.sx,
examples/ffi-jni-class-09-multi-float-args.sx,
examples/ffi-jni-call-11-unsupported-return-diag.sx.
Stale-snapshot drift in tests/expected/ffi-objc-call-03-selector-sharing.ir
and ffi-objc-call-06-sret-return.ir picks up the new BuildOptions
accessor extern decls (is_android, set_manifest_path,
set_keystore_path, jni_main_count, jni_main_foreign_path_at,
jni_main_java_source_at). Verified diff is dead-decl-only.
Chess on Pixel 7 Pro: tap on e2 white pawn -> yellow selection +
green dots on legal e3/e4 targets; tap on e4 -> board updates with
1. e4, "Black to move" + "1. e4" in info panel.
zig build && zig build test && bash tests/run_examples.sh -> 145/145
green. bash tests/cross_compile.sh -> 7/7 green.
Campaign Weeks 3-6 of /Users/agra/.claude/plans/lets-plan-to-move-splendid-pumpkin.md
land in one push: the bundling pipeline that used to live in
src/target.zig (createBundle, embedFramework, extractEntitlements,
buildInfoPlist, codesign) now lives in
library/modules/platform/bundle.sx and runs in the IR interpreter
after target.link() returns.
New language-side surface:
- library/modules/fs.sx — POSIX libc bindings (open/read/write/close,
mkdir/unlink/rmdir, chmod, rename, access, basename/dirname). Variadic
open() lowers to C's varargs via the new args: ..T form. Direct libc
calls bypass *File method dispatch so they work from the post-link
IR interpreter.
- library/modules/process.sx — popen-based run(cmd) returning
ProcessResult{ exit_code, stdout }, plus env() and find_executable().
- library/modules/std.sx — xml_escape(s) and variadic path_join(parts).
- library/modules/compiler.sx — BuildOptions grows
set_post_link_callback / set_post_link_module / binary_path
accessors; bundle_path/bundle_id/codesign_identity/provisioning_profile
setters + accessors; per-target predicates is_macos/is_ios/
is_ios_device/is_ios_simulator + target_triple; framework_count /
framework_at(i) / framework_path_count / framework_path_at(i);
add_asset_dir(src, dest) + asset_dir_count / src_at / dest_at.
Compiler-side wiring:
- src/ir/compiler_hooks.zig — BuildConfig now carries post_link_callback_fn,
post_link_module, binary_path, bundle_*, target_triple,
target_frameworks, target_framework_paths, asset_dirs. Hook registry
exposes every accessor; getters return "" / 0 for unset fields so
bundle.sx can treat absent values uniformly.
- src/ir/host_ffi.zig (new) — dlsym(RTLD_DEFAULT) + arity-switched cdecl
trampolines so #foreign("c") declarations resolve through the host
libc during #run / post-link interpretation.
- src/ir/interp.zig — callForeign dispatch; build_config pointer
injection so accessor hooks see live state during re-entry.
- src/core.zig — keeps the IR module alive past generateCode; exposes
invokeByName / invokeByFuncId so main.zig can re-enter the
interpreter after linking.
- src/main.zig — wires bundle/codesign/provisioning CLI flags +
target_triple + framework lists into BuildConfig; invokes the
post-link callback (by FuncId or by <module>.bundle_main lookup) once
target.link() returns. When --bundle is set but no callback is
registered, auto-falls-back to post_link_module = "platform.bundle"
so the legacy --bundle CLI keeps working for any program that imports
modules/platform/bundle.sx.
Apple .app bundler (library/modules/platform/bundle.sx):
- Single bundle_main entry covers macOS, iOS simulator, iOS device.
Per-target Info.plist switch keys off is_ios()/is_ios_simulator() —
iOS emits UIDeviceFamily / LSRequiresIPhoneOS /
UIApplicationSceneManifest / DTPlatformName (iPhoneOS or
iPhoneSimulator); macOS emits the minimal CFBundle* set.
- iOS-only steps:
- Provisioning embed: fs.read_file + fs.write_file to
<bundle>/embedded.mobileprovision.
- Framework embed: recursive cp -R per -F search path into
<bundle>/Frameworks/<Name>.framework/ (until fs.sx grows list_dir).
- Entitlements extraction: four process.run calls (security cms -D,
plutil -extract Entitlements xml1, plutil -extract
ApplicationIdentifierPrefix.0, plutil -replace application-identifier)
resolving the wildcard <TEAM>.* -> <TEAM>.<bundle_id>.
- Real codesign with --entitlements when present.
- Asset dirs (add_asset_dir): recursive cp -R src/. into <bundle>/dest/.
Missing src is treated as "nothing to do" so projects can register
add_asset_dir("assets", "assets") unconditionally.
Parser:
- parseStmt() now accepts #import \"path\"; and #framework \"Name\"; as
statement-position tokens. Needed for top-level
inline if OS == .android { #import \"modules/platform/android.sx\"; }
blocks (issue-0042 flatten pass surfaces them); chess's
inline-if-with-#import was rejected at parse time before this fix.
Removals from src/target.zig:
- createBundle, embedFramework, extractEntitlements, buildInfoPlist,
codesign (~210 lines). main.zig no longer calls createBundle after
link(); the sx callback is the single entry point.
Tests / regression markers (all run under sx run host JIT):
- examples/115-post-link-callback.sx — callback registration round-trip.
- examples/116-fs-roundtrip.sx — fs.write_file -> fs.read_file -> exists.
- examples/117-process-roundtrip.sx — process.run + env + find_executable.
- examples/118-macos-bundle.sx — macOS .app via bundle_main callback.
- examples/119-interp-cast-ptr-cmp.sx — cast(T) val under interpreter.
- examples/120-interp-variadic-any.sx — variadic ..Any indexing in IR
interpreter.
- examples/121-ios-sim-bundle.sx — iOS-sim cross-compile + .app with
iOS-shaped Info.plist (added to tests/cross_compile.sh as the
ios-sim tuple).
- examples/122-ios-device-bundle.sx — iOS device cross-compile +
full codesign pipeline (provisioning embed + entitlements
extraction + --entitlements codesign). Manually verified end-to-end:
installed via xcrun devicectl device install app + launched
successfully on iPhone 17 Pro.
- examples/123-inline-if-import-in-body.sx — locks in the parser fix.
zig build && zig build test && bash tests/run_examples.sh => 141 passed,
0 failed; bash tests/cross_compile.sh => 7 passed, 0 failed.
Combined slice — gets chess rendering on a Pixel 7 Pro via the
`#jni_main` pipeline. Half-dozen jni_java_emit fixes plus the rebuilt
stdlib android module:
jni_java_emit:
- `#implements Alias;` body members render as Java `implements`
clauses on the class header (space-separated, registry-resolved).
- Drop the implicit `super.<method>(args)` call from the @Override
delegate — interface impls (SurfaceHolder.Callback) have no
super; user calls super explicitly from sx-side via
`super.method(args)` lowered to `CallNonvirtual<T>Method`.
- `static { System.loadLibrary("<libname>"); }` static init block,
lib name derived from the build's `-o` basename.
- `name: Type;` body items render as private Java fields.
- `$` (JNI nested-class shape) → `.` in Java source: e.g.
`android/view/SurfaceHolder$Callback` → `android.view.SurfaceHolder.Callback`.
- Non-void @Override bodies `return` the native delegate's result.
lower.zig:
- `super.method(args)` sugar inside a `#jni_main` (or any
sx-defined `#jni_class`) bodied method lowers to JNI
`CallNonvirtual<T>Method` with the parent class resolved via
`#extends` (default Activity).
- `Alias.new(args)` constructor sugar lowers to JNI
`FindClass + GetMethodID("<init>", sig) + NewObject`.
- `jniMapParamType` stops erasing pointer types so method dispatch
on foreign-class params (`holder.getSurface()`) resolves.
- synthesizeJniMainStub pushes the env arg onto the lexical
`#jni_env` stack so omitted-env `#jni_call` and `super.method`
sites see it.
target.zig:
- Manifest synthesised from `#jni_main` adds
`android:theme="@android:style/Theme.DeviceDefault.NoActionBar.Fullscreen"`
so sx apps own the whole window (no title strip, no status bar).
library/modules/platform/android.sx (NEW):
- Replaces the retired NativeActivity-based module under #jni_main.
- Foreign-class decls for Bundle / Context / Surface / SurfaceHolder
/ SurfaceView / MotionEvent / View / Activity / SurfaceHolderCallback /
AssetManagerJ.
- libandroid / EGL / pthread foreign C decls.
- Helpers consumers call from their Activity body:
`sx_android_forward_assets(env, ctx)`,
`sx_android_attach_window(env, holder)`,
`sx_android_detach_window()`,
`sx_android_set_viewport(w, h)`,
`sx_android_start_render_thread(main_fn)`,
`sx_android_push_touch(action, x, y)`.
- Render thread brings up EGL on the ANativeWindow then calls the
user-supplied entry fn pointer.
- `AndroidPlatform` struct + `impl Platform` (init / begin_frame /
end_frame / poll_events / safe_insets / keyboard / show_keyboard /
hide_keyboard / stop / shutdown / run_frame_loop).
End-to-end verified on a Pixel 7 Pro: chess APK builds via
`sx build --target android --apk ... --bundle-id ... -o ...`, installs
via `adb install -r`, launches and renders the chess board with all
pieces in starting position. No title strip, no flicker. Touch events
reach `sx_android_push_touch` and drain through `poll_events` (debug-
verified) — chess's pipeline-side hit-test routing + DPI-correct
sizing remain as follow-ups.
138 host / 8 cross / `zig build test` all green.
Deletes the entire NativeActivity / native_app_glue / ALooper stack
that the previous Android entry path was built around:
- `examples/99-android-egl-clear.sx` — the demo of the legacy path.
- `library/modules/platform/android.sx` — `AndroidPlatform.init`,
`run_frame_loop`, `sx_android_bootstrap`, `g_android_app`, plus
the ALooper / AInputEvent / ANativeActivity / AConfiguration
foreign decls that fed them. The JNI helpers (`sx_load_javavm_fn`,
`sx_android_get_env`, `sx_query_safe_insets_jni`, the
`ANATIVEACTIVITY_*` offsets) were tied to the ANativeActivity*
delivered to `android_main` — they're stale now that the OS hands
sx code a Java Activity directly via `onCreate(JNIEnv*, jobject)`.
- `library/vendors/sx_android_jni/sx_android_jni.c` — the input-
handler installer (`sx_android_install_input_handler`), which
poked NDK app-pointer field offsets that no longer exist.
`library/modules/platform/android_jni.sx` (the `Activity`/`Window`/
`View`/`WindowInsets` `#jni_class` registry used for safe-insets
dispatch) survives — it's standalone declarative bindings useful from
any `#jni_main` onCreate body. Docstring updated to drop the
"imported from android.sx" framing.
131 host / 4 cross / zig build test all green. End-to-end smoke APK
still produces the expected JNI-mangled symbol +
SxApp-extends-Activity dex.
External consumers (chess) will need to migrate their entry from the
`AndroidPlatform.run_frame_loop` model to the `#jni_main` model
(Java-side Activity drives lifecycle; onSurfaceChanged / Choreographer
drive frames via JNI callbacks). That migration is downstream work.
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.
`sx_query_safe_insets_jni`'s body — previously seven hand-rolled
`#jni_call` sites with verbose JNI descriptor literals — now uses
four `#jni_class` declarations and the DSL method-call form inside
a `#jni_env(env) { ... }` scope. The new shape:
```
WindowInsets :: #jni_class("android/view/WindowInsets") {
getSystemWindowInsetTop :: (self: *Self) -> s32;
...
}
... Activity / Window / View ...
#jni_env(env) {
window := activity.getWindow();
decor := window.getDecorView();
insets := decor.getRootWindowInsets();
top.* = insets.getSystemWindowInsetTop();
...
}
```
Descriptor derivation happens at lower time (jni_descriptor.zig);
slot interning + vtable dispatch shape match the Phase 1C hand-rolled
form byte-for-byte. The function param signature changes from
`activity: *void` to `activity: *Activity` so the DSL can resolve
method names through `foreign_class_map`; the AndroidPlatform.safe_insets
caller adds an `xx` cast at the call site.
Net body shrinks from 14 dispatch lines to 12 (slightly shorter but
the win is type safety + readability — the foreign descriptor
strings are gone). On-device chess regression is the remaining
verification step (Pixel device with safe-area-driven board layout).
Verified locally: zig build, run_examples (129/129), cross_compile
(3/3 — incl. examples/99-android-egl-clear.sx cross-compile to
android target succeeds and produces a valid .o).
Naming caveat: `Activity` / `Window` / `View` / `WindowInsets` are
now top-level names exported by `modules/platform/android.sx`. User
code that imports this module shouldn't redefine these aliases.
Closes the Phase 1D migration for the safe-insets JNI chain. The C
function and its `#foreign` declaration in `android.sx` are gone;
all dispatch now goes through the sx-side `#jni_call` machinery
plus the JavaVM helpers landed in 1.26.
What's gone from `library/vendors/sx_android_jni/sx_android_jni.c`:
- `#include <android/native_activity.h>` and `<jni.h>` (no longer
needed without the JNI body).
- `sx_android_query_safe_insets` — 55 lines of `(*env)->Foo` chain
with manual `goto done` early-exit. Migrated to
`library/modules/platform/android.sx::sx_query_safe_insets_jni`
in 1.25 (15 lines of `#jni_call`).
What stays:
- `sx_android_install_input_handler` — non-JNI; struct-field
assignment against `struct android_app`'s `onInputEvent` slot.
No sx equivalent yet (would need to either land a `#android_app`-
style intrinsic or hand-roll the offset, neither of which is
Phase 1 scope).
- `<android/input.h>` and the `struct sx_android_app_min` mirror
needed by the input-handler installer.
Net diff: -55 lines in the .c file, -1 line `#foreign` decl in
android.sx. Phase 2 (declarative JNI imports) will revisit whether
the .c file can be deleted entirely (the input-handler hop may
move into a different shape).
Verification:
- zig build + zig test + run_examples + cross_compile all green.
Notable: the previously-failing `ffi-objc-call-12-rect-u64-returns`
also passes now — looks like the working-tree `#import c` work
was tidied up alongside.
- chess Android APK rebuilt + reinstalled + launched on Pixel
device; safe-insets behavior unchanged (board top edge sits below
the status bar correctly, all pieces in starting positions, no
status-bar overlap).
`AndroidPlatform.safe_insets` now reaches into the JVM through the
sx helpers from 1.25 + 1.26 instead of the C `sx_android_query_safe_insets`
foreign call:
attached := false;
env := sx_android_get_env(g_android_activity, @attached);
if env != null {
clazz := sx_android_activity_clazz(g_android_activity);
sx_query_safe_insets_jni(env, clazz, @t, @l, @b, @r);
if attached { sx_android_detach_env(g_android_activity); }
}
Chess Android IR now includes the seven `(@SX_JNI_CLS_*, @SX_JNI_MID_*)`
slot pairs (one per unique literal `(name, sig)` pair: getWindow,
getDecorView, getRootWindowInsets, getSystemWindowInset{Top,Left,
Bottom,Right}). First call populates each; subsequent calls hit the
cached jmethodID via the 1.17 lazy-init branch.
The C `sx_android_query_safe_insets` body is now unused; left in place
per the plan ("leave the file in place until Phase 2 deletes it").
Chess Android + iOS-sim both compile clean; host 118/119;
cross-compile 3/3.
On-device chess regression is the next checkpoint — the safe-area
behavior is visible: board must sit below the status bar with
correct top inset on a Pixel 7 Pro with notch. Deferred to the next
session (requires APK build + adb install + screencap).
Adds the JavaVM-side vtable indirection to `library/modules/platform/
android.sx` so the sx caller of `sx_query_safe_insets_jni` (1.25)
can obtain a `JNIEnv*` without the C wrapper. `#jni_call` only
dispatches through `JNIEnv*`'s vtable (a different table from
`JavaVM*`'s), so the JavaVM hop is hand-rolled here.
New decls:
- `JNI_VERSION_1_6` (0x00010006) and the `ANATIVEACTIVITY_*` byte
offsets (8, 24 on 64-bit Android — vm, clazz respectively).
- `sx_load_ptr_at(base, offset)` — load a `*void` field at a raw
byte offset. Used for both ANativeActivity fields and the JavaVM
vtable load.
- `sx_load_javavm_fn(vm, slot)` — load function pointer at the
given vtable slot. `vm` is `JavaVM*` which points to
`JNIInvokeInterface*`; the indirection is `*vm + slot * 8`.
- `sx_android_get_env(activity, out_attached)` — calls `GetEnv`
(slot 6); on `JNI_EDETACHED` falls through to
`AttachCurrentThread` (slot 4), sets `out_attached = true` so
caller can balance with `sx_android_detach_env` (slot 5).
- `sx_android_activity_clazz(activity)` — reads the jobject at byte
offset 24.
Chess Android + iOS-sim builds still clean; cross-compile 3/3
green; host 118/119. The new functions dead-strip until step 1.27
wires them into the safe-insets call site in
`android.sx::AndroidPlatform.safe_insets`.
Phase 1D for `library/vendors/sx_android_jni/sx_android_jni.c` starts
here. Adds `sx_query_safe_insets_jni` to `library/modules/platform/
android.sx` — a sx-side implementation of the JNI dispatch chain
that lives inside the C `sx_android_query_safe_insets` helper.
The C version is ~50 lines of `(*env)->GetMethodID` + `CallObjectMethod`
+ `CallIntMethod` boilerplate with manual `goto done` early-exit
plumbing on every step. The sx version collapses to four
`#jni_call(*void)` chain steps + four `#jni_call(s32)` reads at the
end — each #jni_call internally handles GetObjectClass + GetMethodID
+ Call<Type>Method via the slot interning from 1.17.
Signature differences from the C version:
- The sx version takes `env: *void` directly. The C version derives
it from `ANativeActivity*` via JavaVM's GetEnv/AttachCurrentThread.
Bridging that gap (sx-side JavaVM dispatch OR a tiny C shim that
returns the env) is the next Phase 1D step.
- The activity arg here is the jobject (`ANativeActivity*.clazz`)
rather than the activity pointer itself.
No call sites switched yet. Chess Android still uses the foreign C
function. Cross-compile + chess both targets all clean — verifies
the new function typechecks and lowers, but on-device runtime
verification is deferred to the integration commit.
Six remaining dispatch clusters migrated in one pass:
- `uikit_setup_renderbuffer`: `renderbufferStorage:fromDrawable:` (BOOL).
- `uikit_present_renderbuffer`: `presentRenderbuffer:` (BOOL, every frame).
- `uikit_gl_view_tick`: `targetTimestamp` and `duration` reads (f64,
every frame — three call sites total across the keyboard-anim path
and the frame-closure path).
- `uikit_compute_layer_pixel_size`: `bounds` (CGRect HFA).
- `uikit_touch_location`: `locationInView:` (CGPoint HFA — first
standalone `#objc_call(CGPoint)` exercise, structurally identical to
the 2×f64 NSPoint already verified by ffi-objc-call-05).
- `uikit_first_touch`: `anyObject` (*void).
Net -15 lines. uikit.sx is now 839 lines — Phase 1D started at 937,
so this is -98 cumulative across the migration. Zero `xx objc_msgSend`
typed casts left in the file.
iOS-sim chess regression smoke: launched chess, tapped a black pawn
through the Simulator window, watched the move (d7→d5) play, then a
second tap played d5→d4. The render loop, touch handlers, layout
math, and the BOOL-returning EAGL presentation calls are all on the
exercised path, so this is the strongest runtime verification any
Phase 1D commit has had so far.
22 `sel_registerName` calls remain in the file, all legitimate:
- `class_addMethod` IMP registrations (runtime class build-out).
- SEL-as-arg to dispatch selectors that take a SEL value
(`addObserver:selector:name:object:`,
`displayLinkWithTarget:selector:`). A future `#objc_selector("foo")`
literal would replace these, but it's not part of Phase 1.
The keyboard notification callback. First standalone exercises of
`#objc_call(CGRect)` (HFA — structurally equivalent to UIEdgeInsets,
already verified by 1.25 and ffi-objc-call-07) and `#objc_call(u64)`
(LLVM-equivalent to s64; ffi-objc-call-04 already locks in the i64
return path).
Migrates:
- `userInfo` (*void)
- `objectForKey:` with NSString arg (*void)
- `CGRectValue` (CGRect HFA)
- `doubleValue` (f64)
- `unsignedLongValue` (u64)
- `screen` (*void)
- `bounds` (CGRect HFA)
Net -14 lines. uikit.sx now 854 lines (-83 cumulative across Phase 1D).
iOS-sim chess regression smoke: launch is clean; the callback is
registered through cluster 1.30's notification-center wiring and the
function lowers without IR-verifier complaints. The callback body
itself isn't exercised at runtime by chess startup (the game doesn't
open the soft keyboard) — runtime verification of this specific
function is transitive via the other clusters that exercise the same
call shapes.
The biggest Phase 1D cluster: the iOS scene-lifecycle entry that runs
at every launch. UIWindow alloc/init, UIViewController alloc/init, GL
view alloc/init/install, root-view-controller wiring, layer access +
setOpaque:, EAGL drawable-properties dictionary build,
screen/nativeScale DPI scaling, makeKeyAndVisible, UITextField subview
install, CADisplayLink construct + addToRunLoop. Every return shape
this file uses (void, *void, f64) and every arg shape (BOOL via `xx
0`/`xx 1`, multi-arg selectors `displayLinkWithTarget:selector:` and
`setObject:forKey:`) is exercised by this single launch.
Net -44 lines on this commit (104 → 60). Also drops a stale
`EAGLContext := objc_getClass(...)` decl that wasn't referenced inside
this function — EAGL context creation lives in uikit_create_gl_context
(already migrated in 1.29). uikit.sx is now 868 lines (-69 cumulative
across Phase 1D).
iOS-sim chess regression smoke: app launches cleanly, board renders
with status-bar clearance, sharp DPI scaling, compositor working,
display-link tick driving frames. Every part of the migrated function
is on the launch path and all of it succeeds.
Apple documents `-becomeFirstResponder` and `-resignFirstResponder` as
returning `BOOL`. The pre-`#objc_call` cast pattern in this file used
`u8` because BOOL is ABI-equivalent to a 1-byte unsigned integer on
both i386 (signed char) and arm64 (`bool`). The initial 1.28
migration carried that `u8` typing forward without question; switching
to `bool` matches the documented API and aligns with the BOOL→bool
mapping called out in PLAN-FFI.md Phase 3.
First standalone exercise of `#objc_call(bool)`. The lowering is
identical to `#objc_call(u8)` at the ABI layer (single byte in `w0`
on AAPCS64), but the source-level type is now meaningful.
Three Phase 1D clusters in one commit (user opted for less iOS-sim
verification between each).
1.28 — `show_keyboard` / `hide_keyboard` use `#objc_call(u8)` against
`becomeFirstResponder` / `resignFirstResponder`. Compile-only; chess
startup doesn't reach the keyboard path, so the runtime side of this
cluster is a verification gap to backfill at the end of Phase 1D.
1.29 — `uikit_create_gl_context` migrates `alloc` / `initWithAPI:` /
`setCurrentContext:` and folds in the same `mainScreen.nativeScale`
read shape already migrated in 1.27. EAGL context creation runs on
launch, so this cluster IS runtime-exercised.
1.30 — `uikit_subscribe_keyboard_notifications` migrates the
`defaultCenter` + `addObserver:selector:name:object:` pair. First
standalone exercise of a 4-keyword selector through `#objc_call`.
Notification-center wiring runs at launch, so runtime-exercised.
Net -23 lines across the file.
iOS-sim chess regression smoke: app launches cleanly into a fresh
board state. Status-bar clearance, sharp rendering, and asset loading
all good — confirming clusters 1.25–1.27 still work alongside the new
ones.
Third Phase 1D cluster. `UIScreen.mainScreen.nativeScale` chain reads
through `#objc_call(*void)` + `#objc_call(f64)`. First standalone
`#objc_call(f64)` exercise — `f64` returns had only been covered
indirectly by the 4×f64 UIEdgeInsets HFA path. Net -4 lines.
iOS-sim chess regression smoke: sharp text rendering + accurate touch
hit-testing both confirm `plat.dpi_scale` is being populated correctly
through the new path.
Second Phase 1D cluster. NSBundle.mainBundle.resourcePath chain now
dispatches through `#objc_call(*void)` instead of a shared `msg_o`
typed cast — covers both class-method (`+mainBundle`) and
instance-method (`-resourcePath`) shapes through one intrinsic. Net
-3 lines.
iOS-sim chess regression smoke: app launches with all piece assets
rendered, which is the visible signal that `chdir` to the bundle's
resource path still succeeds.
First Phase 1D migration cluster. `uikit_refresh_safe_insets` reads
`safeAreaInsets` through `#objc_call(UIEdgeInsets)` instead of the
hand-typed `objc_msgSend` cast + `sel_registerName` triple, and a dead
`sel_safe_insets` selector decl in `uikit_scene_will_connect_ios` goes
away with it. Net -3 lines.
iOS-sim chess regression smoke: SxChess launches, board renders with
correct status-bar clearance — `safe_top` is populated correctly,
which is the actual ABI under test (32 B HFA returned in v0..v3).