ns_string's only caller was impl Into(*NSString) for string, so +stringWithUTF8String: is inlined there. c_string's one use (NSBundle.resourcePath in uikit) becomes rsrc.UTF8String() with resourcePath retyped *NSString. ffi-objc-call-06 and ffi-objc-dsl-07 .ir snapshots regenerated — they only drop the now-absent extern declares.
NSLog's fmt, addObserver's name, UIApplicationMain's principal-class, CADisplayLink's run-loop mode, and metal's newLibraryWithSource/newFunctionWithName string args are retyped *NSString, so their call sites read xx "..." instead of ns_string("...".ptr). ns_string is now used only by impl Into(*NSString) for string.
Adds an NSString foreign class and impl Into(*NSString) for string so a string literal flows into any *NSString slot via xx. uikit's keyboard userInfo lookups now read objectForKey(xx "...") instead of ns_string("...".ptr), and objectForKey's key param is retyped *NSString.
ffi-objc-call-06 .ir snapshot regenerated: declaring the NSString type adds its reflection thunks (struct_to_string/pointer_to_string), same as the existing NSObject/NSDictionary. Runtime output unchanged.
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.
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).
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.
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).
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.
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.
Walked back the manual-interpolation + CABasicAnimation+presentationLayer
attempts at lockstep keyboard inset. Both leave a visible frame of lag
because the lockstep problem is structural, not implementation-detail:
- GL renderbuffer content is baked at presentRenderbuffer() time.
- The CoreAnimation compositor can interpolate the *position* of a
CALayer per-vsync but cannot reach into our renderbuffer's pixels.
- The GPU pipeline (CADisplayLink → command build → present →
compositor → display) is 2-3 frames deep on iOS GLES, so even
`targetTimestamp`-based prediction is one to two frames short.
The architectural escape that doesn't move the GL view (rejected for
edge cases) is to give CoreAnimation a renderable handle it can sync
on. That means **Metal**:
- CAMetalLayer + MTLDrawable.presentAtTime(_:) caps the pipeline at
exactly one frame.
- With targetTimestamp prediction + curve-accurate keyboard math,
our drawable lands at the same vsync as UIKit's keyboard.
- Renderer modernization (Metal/Vulkan/WebGPU per platform) was on
the roadmap anyway; lockstep is the forcing function.
This commit keeps the keyboard observer + show/hide_keyboard wiring
intact and SNAPS keyboard_height when the observer fires. Behavior:
the chess board doesn't shift during the keyboard animation; it shifts
in one step when the observer fires. Less smooth than the broken
attempt but honest.
Plan for the Metal port (next):
- library/modules/gpu/{metal,vulkan,webgpu}.sx + a `GPU` protocol
analogous to Platform.
- Port modules/ui/renderer.sx shaders from GLSL to MSL.
- SxGLView becomes SxMetalView; CAEAGLLayer becomes CAMetalLayer.
- Lockstep falls out of MTLDrawable.presentAtTime(targetTimestamp).
UIKitPlatform now reads `[UIView safeAreaInsets]` (UIEdgeInsets = 32-byte
struct: top, left, bottom, right CGFloats) in begin_frame, and subscribes
to UIKeyboardWillChangeFrameNotification on NSNotificationCenter. The
chess game's build_ui pads its root by `g_safe_insets`, so the Dynamic
Island no longer overlaps the board on iPhone 17 Pro — all 8 ranks and
files are visible.
Struct returns >16 bytes (UIEdgeInsets, CGRect) go through the arm64
x8 indirect-result-pointer convention; expressing the return type on a
typed `objc_msgSend` fn-pointer cast generates the right call sequence.
Same pattern used to unwrap the keyboard's CGRect from NSValue
(UIKeyboardFrameEndUserInfoKey).
show_keyboard / hide_keyboard now drive a hidden UITextField subview as
the firstResponder source. resignFirstResponder dismisses; observer
fires with height=0 → safe_insets bottom collapses.
Deferred (next iteration): wrap the inset update in
[UIView animateWithDuration: animations:^{ ... }] to land in the same
CoreAnimation transaction as the keyboard. sx doesn't have block
syntax yet — we'd need a C shim that takes an fn-ptr and builds the
block. Today the inset snaps while the keyboard slides; the lag is
visible but the rest of the wiring is in place.
examples/66-uikit-platform.sx updated: each tap toggles the keyboard
+ advances the clear color (red→green→blue), so the observer can be
observed firing via the visible keyboard slide.
What works on iOS sim now:
- pure-UIKit boot via UIApplicationMain (no SDL3 on iOS)
- SxGLView (CAEAGLLayer) + EAGLContext(GLES3) + CADisplayLink
- GLES3 shader path in modules/ui/renderer.sx (was wasm-only; now
wasm-OR-ios)
- UITouch -> ui.Event translation (mouse_down/moved/up) on touchesBegan/
Moved/Ended/Cancelled. Verified by tapping the chess board: the
expected pawn highlights and its legal moves show as green dots.
- chdir to NSBundle.mainBundle.resourcePath inside UIKitPlatform.init so
the game's relative fopen("assets/...") calls resolve.
Required restructuring to fix four problems discovered along the way:
1. GL context + load_gl must happen BEFORE UIApplicationMain so the
game's pipeline.init (which compiles shaders) doesn't crash on null
function pointers. Pulled EAGLContext creation + load_gl out of
didFinishLaunching: into UIKitPlatform.init via uikit_create_gl_context.
2. UIScreen.nativeScale returns CGFloat (=double on 64-bit Apple).
Reading it through a `(*void, *void) -> f32` msgSend signature
clobbers the value to 0 — the upper 32 bits of d0 land where the f32
reads from. Replaced msg_f with msg_d returning f64 (and added
msg_odbl for setContentScaleFactor: which takes CGFloat).
3. `xx <f64-call-result>` directly assigned to an f32 field through a
sema path lowers as `sitofp` (integer→float) on the double — LLVM
verification rejects it. Workaround: hoist into an `f64` local first.
4. The renderer was selecting the GLSL 330 core shader on every non-wasm
target, including iOS GLES3 where it silently fails to compile and
no quads render. Added OS == .ios to the GLES branch.
Game changes:
- main.sx: g_plat is now a boxed `Platform` (not concrete *SdlPlatform).
Backend chosen per-target via `inline if OS == .ios { ... }`. The
ESC-to-stop handling is OS-guarded (mobile apps don't quit on key
press, and SDL_Keycode references would force-link SDL on iOS).
- build.sx: iOS no longer adds SDL3; it adds UIKit + OpenGLES +
QuartzCore instead.
- delta_time and viewport dims are now mirrored to free globals so the
dock subsystem (`g_dock_delta_time = @g_delta_time`) and build_ui
layout decisions don't need a pointer through the boxed protocol.
Other:
- Added `stop()` to the Platform protocol (no-op on UIKitPlatform).
- examples/66-uikit-platform.sx updated: taps advance the clear color
(red → green → blue) — smoke test for the touch IMP wiring.
- shutdown() on UIKitPlatform is a no-op (mobile apps don't tear down).
Outstanding for next session:
- The Dynamic Island notch overlaps the top of the board because we
haven't read UIView.safeAreaInsets yet (CGRect/UIEdgeInsets struct
returns require a different msgSend ABI than we currently express).
- Keyboard observer (UIKeyboardWillChangeFrameNotification + animation
duration) — the load-bearing iOS feature.
- Real-device codesigning workflow for the new build.
Two more sx compiler bugs to file out of this work:
- xx(f64 call result) → f32 emits sitofp (problem #3 above).
- Inline `#import` inside `inline if` fails to parse (we worked around
by importing both backends unconditionally; the unused-backend's
Obj-C calls are gated by `inline if OS == .ios`).
End-to-end on iOS sim: UIKitPlatform boots an SxAppDelegate, installs
an SxGLView (UIView subclass overriding +layerClass to return
CAEAGLLayer) as the root view controller's view, sets the drawable
properties (EAGLColorFormatRGBA8, non-retained backing — looked up by
dlsym so pointer-identity-checked constants match), creates an
EAGLContext (GLES3), and registers a CADisplayLink that invokes the
user's frame closure on every vsync. end_frame presents the
renderbuffer via [EAGLContext presentRenderbuffer:].
The renderbuffer is allocated lazily in -[SxGLView layoutSubviews] once
the layer has its real on-screen bounds — allocating earlier (e.g. in
didFinishLaunching) failed with INCOMPLETE_ATTACHMENT because the
SxGLView's frame was still zero at that point. Setting the SxGLView
as the VC's `view` (via setView:) lets the standard VC layout pipeline
size it to the window without us having to read CGRect struct returns
from objc_msgSend.
EAGL drawableProperties dict keys/values are dlsym'd from OpenGLES —
the framework checks them by pointer identity, so synthesized NSString
literals with the same contents don't work.
examples/66-uikit-platform.sx — runnable smoke test that cycles the
screen color (red → green → blue every 30 frames) so you can confirm
the display-link tick and present pipeline.
modules/opengl.sx gains glGenFramebuffers, glGenRenderbuffers,
glBindFramebuffer, glBindRenderbuffer, glFramebufferRenderbuffer,
glGetRenderbufferParameteriv, glCheckFramebufferStatus — needed for
the iOS GLES FBO-to-renderbuffer setup. They're wired into load_gl
so SDL and the iOS dlsym loader both pick them up.
Compiles cleanly on macOS / WASM / iOS-sim. Non-iOS targets never
reference the unresolved UIKit/QuartzCore/OpenGLES symbols because
every Obj-C touch lives inside `inline if OS == .ios`.
Game's iOS path still goes through SDL3 for now. Touch events + game
wire-up + keyboard observer = next steps.