From 66f84f67b87c438f5a6f7dbe4e4f706c011cde81 Mon Sep 17 00:00:00 2001 From: agra Date: Tue, 26 May 2026 07:32:57 +0300 Subject: [PATCH] ffi M3.1 + M1.2 A.3 refactor: self=Obj-C id, self.field via ivar; SxAppDelegate migrated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(___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. --- library/modules/platform/uikit.sx | 56 ++++-- src/ir/emit_llvm.zig | 37 +++- src/ir/lower.zig | 177 ++++++++++++++---- .../142-objc-class-method-lowering.ir | 15 +- 4 files changed, 222 insertions(+), 63 deletions(-) diff --git a/library/modules/platform/uikit.sx b/library/modules/platform/uikit.sx index 846603e..7c7f1eb 100644 --- a/library/modules/platform/uikit.sx +++ b/library/modules/platform/uikit.sx @@ -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), diff --git a/src/ir/emit_llvm.zig b/src/ir/emit_llvm.zig index e7f57b7..cdbf5a6 100644 --- a/src/ir/emit_llvm.zig +++ b/src/ir/emit_llvm.zig @@ -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; diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 8dfba49..7ce9072 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -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(___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 * + /// 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(___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_)`. 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 `@.(default_ctx, state, ...user_args)`. + // Call sx body `@.(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)); diff --git a/tests/expected/142-objc-class-method-lowering.ir b/tests/expected/142-objc-class-method-lowering.ir index 7978b48..4622e6e 100644 --- a/tests/expected/142-objc-class-method-lowering.ir +++ b/tests/expected/142-objc-class-method-lowering.ir @@ -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 }