ffi M3.1 + M1.2 A.3 refactor: self=Obj-C id, self.field via ivar; SxAppDelegate migrated

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.
This commit is contained in:
agra
2026-05-26 07:32:57 +03:00
parent ea32f8a27a
commit 66f84f67b8
4 changed files with 222 additions and 63 deletions

View File

@@ -140,7 +140,17 @@ UIScreen :: #foreign #objc_class("UIScreen") {
bounds :: (self: *Self) -> CGRect;
}
// UIResponder is the root for keyboard / touch / focus dispatch.
// Most UIKit classes inherit from it; sx-defined classes that
// participate in lifecycle callbacks (delegates, scene delegates)
// extend it so the runtime picks up the responder-chain behavior.
UIResponder :: #foreign #objc_class("UIResponder") {
becomeFirstResponder :: (self: *Self) -> s8;
resignFirstResponder :: (self: *Self) -> s8;
}
UIView :: #foreign #objc_class("UIView") {
#extends UIResponder;
safeAreaInsets :: (self: *Self) -> UIEdgeInsets;
addSubview :: (self: *Self, view: *void);
layer :: (self: *Self) -> *CALayer;
@@ -162,12 +172,29 @@ UIViewController :: #foreign #objc_class("UIViewController") {
}
UITextField :: #foreign #objc_class("UITextField") {
alloc :: () -> *UITextField;
init :: (self: *Self) -> *UITextField;
// Inherited from UIResponder via the runtime; declared here directly
// until `#extends UIResponder` lands (Phase 3.4).
becomeFirstResponder :: (self: *Self) -> s8;
resignFirstResponder :: (self: *Self) -> s8;
#extends UIResponder;
alloc :: () -> *UITextField;
init :: (self: *Self) -> *UITextField;
}
// SxAppDelegate — UIApplicationMain's principal class. Replaces the
// M3.1 hand-rolled objc_allocateClassPair + class_addMethod sequence
// in uikit_register_classes. The method bodies forward to the
// existing legacy IMP free functions so we don't have to inline 70+
// lines of keyboard-frame logic here.
SxAppDelegate :: #objc_class("SxAppDelegate") {
#extends UIResponder;
application_didFinishLaunchingWithOptions :: (self: *Self, app: *void, opts: *void) -> BOOL {
return xx uikit_did_finish_launching(xx self, xx 0, app, opts);
}
sxKeyboardWillChangeFrame :: (self: *Self, notification: *void) {
uikit_keyboard_will_change_frame(xx self, xx 0, notification);
}
alloc :: () -> *SxAppDelegate;
init :: (self: *SxAppDelegate) -> *SxAppDelegate;
}
// GLenum constants for renderbuffer/framebuffer setup that aren't in opengl.sx's
@@ -401,24 +428,19 @@ uikit_chdir_to_bundle :: () {
uikit_register_classes :: () {
inline if OS == .ios {
UIResponder := objc_getClass("UIResponder".ptr);
SxAppDelegate := objc_allocateClassPair(UIResponder, "SxAppDelegate".ptr, 0);
// SxAppDelegate is now declared as `#objc_class("SxAppDelegate")`
// (M3.1) — the compiler synthesises its IMPs and class-pair
// registration at module init. The old hand-rolled
// objc_allocateClassPair + class_addMethod sequence is gone.
class_addMethod(SxAppDelegate,
sel_registerName("application:didFinishLaunchingWithOptions:".ptr),
xx uikit_did_finish_launching, "c@:@@".ptr);
class_addMethod(SxAppDelegate,
sel_registerName("sxKeyboardWillChangeFrame:".ptr),
xx uikit_keyboard_will_change_frame, "v@:@".ptr);
objc_registerClassPair(SxAppDelegate);
UIResponder_cls := objc_getClass("UIResponder".ptr);
// SxSceneDelegate handles the per-scene UI setup. iOS 13+ scene-based
// lifecycle: didFinishLaunching is too early for the window — the
// UIWindowScene doesn't connect until `scene:willConnectTo:options:`.
// The class is named in Info.plist's UIApplicationSceneManifest →
// UISceneDelegateClassName.
SxSceneDelegate := objc_allocateClassPair(UIResponder, "SxSceneDelegate".ptr, 0);
SxSceneDelegate := objc_allocateClassPair(UIResponder_cls, "SxSceneDelegate".ptr, 0);
class_addMethod(SxSceneDelegate,
sel_registerName("scene:willConnectToSession:options:".ptr),

View File

@@ -735,8 +735,17 @@ pub const LLVMEmitter = struct {
}
_ = c.LLVMBuildRetVoid(self.builder);
// Register in @llvm.global_ctors + inject into main for ORC JIT.
self.appendModuleCtor(ctor, ctor_ty);
// Inject the call into main's entry block ONLY — skip
// @llvm.global_ctors. Apple's frameworks (UIKit on iOS,
// AppKit on macOS) register their Obj-C classes during
// dyld's image-init phase, which overlaps global_ctors. If
// we ran there too, `objc_getClass(\"UIResponder\")` would
// return null and `objc_allocateClassPair(null, ...)` would
// crash inside objc_registerClassPair. main's entry runs
// AFTER dyld's framework init is complete but BEFORE user
// code (UIApplicationMain), so the runtime sees the parent
// class properly.
self.injectCtorIntoMain(ctor, ctor_ty);
_ = i32_ty;
}
@@ -778,6 +787,30 @@ pub const LLVMEmitter = struct {
/// global if not present, extending the array if so) AND inject a
/// direct call from `main`'s entry block so the ORC JIT path runs
/// the constructor too.
/// Inject a call to `ctor()` at the start of `main`'s entry block
/// (past any existing init calls). Used by class-pair init etc.
/// that need to run BEFORE user code but AFTER dyld's framework
/// load — global_ctors is too early because Apple frameworks
/// (UIKit etc.) register their Obj-C classes during their own
/// init phase that overlaps ours.
fn injectCtorIntoMain(self: *LLVMEmitter, ctor: c.LLVMValueRef, ctor_ty: c.LLVMTypeRef) void {
const main_z = "main";
const main_fn = c.LLVMGetNamedFunction(self.llvm_module, main_z);
if (main_fn == null) return;
const entry_bb = c.LLVMGetEntryBasicBlock(main_fn);
var insert_before = c.LLVMGetFirstInstruction(entry_bb);
while (insert_before != null) : (insert_before = c.LLVMGetNextInstruction(insert_before)) {
if (c.LLVMGetInstructionOpcode(insert_before) != c.LLVMCall) break;
}
if (insert_before != null) {
c.LLVMPositionBuilderBefore(self.builder, insert_before);
} else {
c.LLVMPositionBuilderAtEnd(self.builder, entry_bb);
}
var no_args: [0]c.LLVMValueRef = .{};
_ = c.LLVMBuildCall2(self.builder, ctor_ty, ctor, &no_args, 0, "");
}
fn appendModuleCtor(self: *LLVMEmitter, ctor: c.LLVMValueRef, ctor_ty: c.LLVMTypeRef) void {
const i32_ty = self.cached_i32;
const ptr_ty = self.cached_ptr;

View File

@@ -1657,16 +1657,33 @@ pub const Lowering = struct {
}
},
.field_access => |fa| {
// M2.2 — `obj.field = val` for an Obj-C `#property`
// field dispatches as `[obj setField:val]` through
// objc_msgSend. Skip the struct-pointer / GEP path
// entirely; receivers are opaque Obj-C ids.
// M2.2 — `obj.field = val` for an Obj-C `#property` field
// dispatches via objc_msgSend `setField:`. Skip struct-
// pointer / GEP entirely; receivers are opaque Obj-C ids.
// Compound ops on properties are deferred (need load-via-
// getter + op + store-via-setter — Month 4 ARC territory).
if (asgn.op == .assign) {
if (self.lookupObjcPropertyOnPointer(fa.object, fa.field)) |prop| {
self.lowerObjcPropertySetter(fa.object, prop, val);
return;
}
}
// M1.2 A.3 — `self.field [op]= val` on a sx-defined Obj-C
// class instance field (NOT a #property): write through
// the __sx_state ivar. Handles plain assignment AND
// compound ops (+=, -=, etc.) via storeOrCompound.
if (self.lookupObjcDefinedStateFieldOnPointer(fa.object, fa.field)) |info| {
const obj_ref = self.lowerExpr(fa.object);
const state_ptr = self.lowerObjcDefinedStateForObj(obj_ref, info.fcd) orelse return;
const ptr_void = self.module.types.ptrTo(.void);
const field_addr = self.builder.emit(.{ .struct_gep = .{
.base = state_ptr,
.field_index = info.field_idx,
.base_type = info.state_ty,
} }, ptr_void);
self.storeOrCompound(field_addr, val, asgn.op, info.field_ty);
return;
}
var obj_ptr = self.lowerExprAsPtr(fa.object);
var obj_ty = self.inferExprType(fa.object);
@@ -3610,6 +3627,16 @@ pub const Lowering = struct {
return self.lowerObjcPropertyGetter(fa.object, prop, fa.field, span);
}
// M1.2 A.3 — `self.field` (or `obj.field`) on a *sx-defined-class
// pointer for a plain instance field (NOT a #property) lowers as
// `object_getIvar(obj, load(__<Cls>_state_ivar))` + struct_gep on
// the state struct + load. The receiver is the opaque Obj-C id
// (matching Apple's `self` semantics); the state lives in the
// hidden `__sx_state` ivar.
if (self.lookupObjcDefinedStateFieldOnPointer(fa.object, fa.field)) |info| {
return self.lowerObjcDefinedStateFieldRead(fa.object, info);
}
var obj = self.lowerExpr(fa.object);
var obj_ty = self.inferExprType(fa.object);
@@ -8858,17 +8885,17 @@ pub const Lowering = struct {
/// Resolve a type node, checking type_bindings first for generic type params.
fn resolveTypeWithBindings(self: *Lowering, node: *const Node) TypeId {
// *Self substitution for sx-defined `#objc_class` method bodies
// (M1.2 A.2b). `current_foreign_class` is set by lowerFunction
// and registerObjcDefinedClassMethods when the surrounding
// function is a class method. `Self` inside the body resolves
// to the class's hidden state-struct type — `self.field` then
// works as a plain struct field access (M1.2 A.3 "free if
// types align").
// `*Self` substitution inside foreign-class member declarations
// — both foreign and sx-defined — resolves to the class's own
// 0-field stub struct (i.e. the opaque Obj-C pointer type).
// This matches the Obj-C idiom where `self` IS the object.
// `self.field` access on sx-defined classes is rewritten by
// lowerFieldAccess to go through the `__sx_state` ivar
// (object_getIvar + struct_gep) when needed — see M1.2 A.3.
if (node.data == .type_expr and std.mem.eql(u8, node.data.type_expr.name, "Self")) {
if (self.current_foreign_class) |fcd| {
if (!fcd.is_foreign and fcd.runtime == .objc_class) {
return self.objcDefinedStateStructType(fcd);
if (fcd.runtime == .objc_class or fcd.runtime == .objc_protocol) {
return self.foreignClassStructType(fcd);
}
}
}
@@ -10617,6 +10644,10 @@ pub const Lowering = struct {
if (self.lookupObjcPropertyOnPointer(fa.object, fa.field)) |prop| {
return self.resolveType(prop.field_type);
}
// M1.2 A.3 — sx-defined class state field returns the field's type.
if (self.lookupObjcDefinedStateFieldOnPointer(fa.object, fa.field)) |info| {
return info.field_ty;
}
var obj_ty = self.inferExprType(fa.object);
// Auto-deref: if object is a pointer, resolve through it (matches lowerFieldAccess behavior)
@@ -11681,6 +11712,93 @@ pub const Lowering = struct {
return null;
}
const ObjcDefinedStateField = struct {
field_ty: TypeId,
state_ty: TypeId,
field_idx: u32,
fcd: *const ast.ForeignClassDecl,
};
/// State-field-access info: if obj_expr is *<sx-defined-class>
/// and `field_name` is in the state struct (not a property),
/// returns the field's TypeId, the state struct's TypeId, and
/// the field's index. M1.2 A.3 supports.
fn lookupObjcDefinedStateFieldOnPointer(self: *Lowering, obj_expr: *const ast.Node, field_name: []const u8) ?ObjcDefinedStateField {
const obj_ty = self.inferExprType(obj_expr);
if (obj_ty.isBuiltin()) return null;
const ptr_info = self.module.types.get(obj_ty);
if (ptr_info != .pointer) return null;
const pointee_info = self.module.types.get(ptr_info.pointer.pointee);
if (pointee_info != .@"struct") return null;
const struct_name = self.module.types.getString(pointee_info.@"struct".name);
const fcd = self.foreign_class_map.get(struct_name) orelse return null;
// Only sx-defined Obj-C classes have a state struct. Foreign
// classes' fields are purely declaration metadata (no state).
if (fcd.is_foreign or fcd.runtime != .objc_class) return null;
// Skip property fields — those dispatch via the M2.2 getter/setter
// path. Plain instance fields take the ivar+gep path.
for (fcd.members) |m| switch (m) {
.field => |f| {
if (std.mem.eql(u8, f.name, field_name)) {
if (f.is_property) return null;
const state_ty = self.objcDefinedStateStructType(fcd);
const state_info = self.module.types.get(state_ty);
if (state_info != .@"struct") return null;
const fname_id = self.module.types.internString(f.name);
for (state_info.@"struct".fields, 0..) |sf, idx| {
if (sf.name == fname_id) {
return .{
.field_ty = sf.ty,
.state_ty = state_ty,
.field_idx = @intCast(idx),
.fcd = fcd,
};
}
}
return null;
}
},
else => {},
};
return null;
}
/// Lower a read of `self.field` (or `obj.field`) on a sx-defined
/// Obj-C class: `state = object_getIvar(self, load(ivar_global))`
/// then `struct_gep(state, idx)` + load. M1.2 A.3 — the runtime
/// hop through the hidden ivar.
fn lowerObjcDefinedStateFieldRead(
self: *Lowering,
obj_expr: *const ast.Node,
info: ObjcDefinedStateField,
) Ref {
const obj_ref = self.lowerExpr(obj_expr);
const state_ptr = self.lowerObjcDefinedStateForObj(obj_ref, info.fcd) orelse return Ref.none;
const ptr_void = self.module.types.ptrTo(.void);
const field_addr = self.builder.emit(.{ .struct_gep = .{
.base = state_ptr,
.field_index = info.field_idx,
.base_type = info.state_ty,
} }, ptr_void);
return self.builder.load(field_addr, info.field_ty);
}
/// `state = object_getIvar(obj, load(__<Cls>_state_ivar))`. Shared
/// helper for state-field read + write (M1.2 A.3).
fn lowerObjcDefinedStateForObj(self: *Lowering, obj_ref: Ref, fcd: *const ast.ForeignClassDecl) ?Ref {
const ptr_void = self.module.types.ptrTo(.void);
const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return null;
defer self.alloc.free(ivar_global_name);
const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse return null;
const ivar_addr = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void);
const ivar_handle = self.builder.load(ivar_addr, ptr_void);
const get_ivar_fid = self.ensureCRuntimeDecl("object_getIvar", &.{ ptr_void, ptr_void }, ptr_void);
const args = self.alloc.alloc(Ref, 2) catch return null;
args[0] = obj_ref;
args[1] = ivar_handle;
return self.builder.emit(.{ .call = .{ .callee = get_ivar_fid, .args = args } }, ptr_void);
}
/// Lower `obj.field` for an Obj-C `#property` field as
/// `objc_msg_send(obj, sel_<fieldName>)`. M2.2 — getter side.
/// The setter side lives in the assignment-statement lowering.
@@ -12082,39 +12200,27 @@ pub const Lowering = struct {
const entry = self.builder.appendBlock(entry_name, &.{});
self.builder.switchToBlock(entry);
// (1) Load ivar handle from per-class global.
const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return;
defer self.alloc.free(ivar_global_name);
const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse return;
const ivar_addr = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void);
const ivar_handle = self.builder.load(ivar_addr, ptr_void);
// (2) state = object_getIvar(obj, ivar_handle).
const get_ivar_fid = self.ensureCRuntimeDecl("object_getIvar", &.{ ptr_void, ptr_void }, ptr_void);
// Pass the Obj-C receiver pointer through to the sx body as
// `self`. The body's `self: *Self` type resolves to the
// foreign-class stub (the opaque Obj-C type), matching Apple's
// Obj-C semantics where `self` IS the object. `self.field`
// access on a sx-defined class is rewritten by lowerFieldAccess
// to go through `object_getIvar(self, __sx_state_ivar)` and
// a struct_gep on the state struct — see M1.2 A.3.
const obj_ref = Ref.fromIndex(0);
const get_ivar_args = self.alloc.alloc(Ref, 2) catch return;
get_ivar_args[0] = obj_ref;
get_ivar_args[1] = ivar_handle;
const state_ptr = self.builder.emit(.{ .call = .{
.callee = get_ivar_fid,
.args = get_ivar_args,
} }, ptr_void);
// (3) Call sx body `@<Cls>.<method>(default_ctx, state, ...user_args)`.
// Call sx body `@<Cls>.<method>(default_ctx, self, ...user_args)`.
const body_name = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ fcd.name, md.name }) catch return;
defer self.alloc.free(body_name);
const body_fid = self.resolveFuncByName(body_name) orelse return;
// Locate __sx_default_context global. When implicit_ctx is off
// (no std.sx imported), the body has no __sx_ctx param either —
// skip the ctx prepend.
const ctx_ref: ?Ref = blk: {
if (!self.implicit_ctx_enabled) break :blk null;
const dctx_gi = self.global_names.get("__sx_default_context") orelse break :blk null;
break :blk self.builder.emit(.{ .global_addr = dctx_gi.id }, ptr_void);
};
// Build arg list: [ctx?] + state + user_args.
// Build arg list: [ctx?] + self + user_args.
const num_user_args = params_slice.len - 2; // minus obj + _cmd
const num_call_args = (if (ctx_ref != null) @as(usize, 1) else 0) + 1 + num_user_args;
const call_args = self.alloc.alloc(Ref, num_call_args) catch return;
@@ -12123,9 +12229,8 @@ pub const Lowering = struct {
call_args[idx] = c_ref;
idx += 1;
}
call_args[idx] = state_ptr;
call_args[idx] = obj_ref;
idx += 1;
// User args come from imp params slots 2..N.
var ip: usize = 2;
while (ip < params_slice.len) : (ip += 1) {
call_args[idx] = Ref.fromIndex(@intCast(ip));

View File

@@ -35,7 +35,6 @@
@OBJC_METH_VAR_TYPE_.21 = private unnamed_addr constant [4 x i8] c"v@:\00"
@OBJC_METH_VAR_NAME_.22 = private unnamed_addr constant [6 x i8] c"alloc\00"
@OBJC_METH_VAR_TYPE_.23 = private unnamed_addr constant [4 x i8] c"@@:\00"
@llvm.global_ctors = appending global [1 x { i32, ptr, ptr }] [{ i32, ptr, ptr } { i32 65535, ptr @__sx_objc_defined_class_init, ptr null }]
; Function Attrs: nounwind
declare void @out(ptr) #0
@@ -739,7 +738,9 @@ entry:
%alloca = alloca ptr, align 8
store ptr %1, ptr %alloca, align 8
%load = load ptr, ptr %alloca, align 8
%gep = getelementptr inbounds { i32 }, ptr %load, i32 0, i32 0
%loadN = load ptr, ptr @__SxFoo_state_ivar, align 8
%call = call ptr @object_getIvar(ptr %load, ptr %loadN)
%gep = getelementptr inbounds { i32 }, ptr %call, i32 0, i32 0
%loadN = load i32, ptr %gep, align 4
%add = add i32 %loadN, 1
store i32 %add, ptr %gep, align 4
@@ -792,6 +793,9 @@ entry:
ret { ptr, i64 } %call
}
; Function Attrs: nounwind
declare ptr @object_getIvar(ptr, ptr) #0
; Function Attrs: nounwind
define ptr @__SxFoo_alloc_imp(ptr %0, ptr %1) #0 {
entry:
@@ -827,9 +831,6 @@ entry:
ret void
}
; Function Attrs: nounwind
declare ptr @object_getIvar(ptr, ptr) #0
; Function Attrs: nounwind
declare ptr @sel_registerName(ptr) #0
@@ -839,9 +840,7 @@ declare void @objc_msgSendSuper2(ptr, ptr) #0
; Function Attrs: nounwind
define void @__SxFoo_bump_imp(ptr %0, ptr %1) #0 {
entry:
%load = load ptr, ptr @__SxFoo_state_ivar, align 8
%call = call ptr @object_getIvar(ptr %0, ptr %load)
call void @SxFoo.bump(ptr @__sx_default_context, ptr %call)
call void @SxFoo.bump(ptr @__sx_default_context, ptr %0)
ret void
}