luv

Workshop wiki

objective-c-and-metal.org

Objective-C as a foreign object system

Objective-C interop is a reusable substrate #M2PDTP

Luv's Objective-C interface began as a prerequisite for Metal, but its useful boundary is the Apple object system itself: runtime classes and selectors, declared message ABIs, explicit ownership, native exceptions, and inspectable trace events. Cocoa delegates, framework objects, and future callback work can reuse that substrate without becoming Metal concepts.

The native Metal 4 backend (#4M91DG) is now a separate page. It owns SDL presentation, the GPU protocol implementation, shader lowering, compilation, and the live pipeline roadmap. This page owns how Lisp crosses Objective-C safely and how that crossing remains pleasant to inspect and redefine. #JSKLEI names the object boundary, #WB5XQN the declaration surface, and #M69ML3 the native ABI escape hatches.

Objective-C is a foreign object system, not a wrapper list #JSKLEI

Apple's metal-cpp proves that a thin mapping over Objective-C can feel like a native object interface. Putting a handwritten C function in front of every member would instead make luv own a second mechanical spelling of each Apple framework. The smaller substrate is the runtime itself: classes, selectors, object pointers, declared method signatures, messages, and ownership.

LispWorks' Objective-C interface is the conceptual precedent. Its wider surface covers selectors, structures, autorelease pools, retain/release, protocols, and Lisp implementations of Objective-C methods. Luv does not need that complete system before drawing, but the precedent makes the right abstraction level clear.

The meaningful boundary objects are small and explicit:

Ordinary execution calls objc_msgSend directly. A trace event is allocated only while with-objective-c-trace is active, and the native NSInvocation bridge is entered only while with-objective-c-exception-handling is active. There is no per-send CLOS object or generic dispatch. Dense shader data, vertex bytes, and argument table entries likewise remain dense data.

Declarations generate typed messages #WB5XQN

A selector string is not enough to call objc_msgSend correctly. The caller must know exact native argument and result types, including by-value structure and architecture-specific conventions. Message sending is therefore declaration-led rather than a magical untyped send:

define-objective-c-messagenew-command-queue
"newMTL4CommandQueue":object:ownership:owned:class"MTL4CommandQueue"
define-objective-c-messageset-device
"setDevice:":void
device:object

The definition site contains the ABI. The macro retains plain selector metadata and creates an ordinary direct-calling Lisp function. Runtime method signatures validate declared storage sizes on the explicitly caught path; they do not silently replace the explicit source declaration.

Foundation owns the small new, drain, retain, release, and UTF8String vocabulary. Framework files own demand-driven declarations beside ordinary C entry points such as MTLCreateSystemDefaultDevice. Header generation becomes attractive only after several real declarations make its policy clear.

Re-evaluating a declaration replaces the ordinary function and its metadata. That is the live-system property worth preserving; per-call instances and a generic invoker hierarchy added indirection without improving redefinition.

The hybrid boundary is an explicit escape hatch #M69ML3

The difficult part is not registering a selector. It is calling every ABI form correctly and receiving callbacks under Lisp's runtime and thread rules. Blocks, SIMD/vector values, variadic methods, NSError ** results, and callbacks on arbitrary queues each add obligations.

The boundary is deliberately hybrid:

The native companion is an ABI tool, not a second object model. If helper functions start mirroring ordinary framework methods, the boundary has slipped back toward the wrapper list this design avoids.

CGSize and MTLClearColor proved that structures remain first-class message ABI types. The caught path copies their exact foreign storage through NSInvocation; the ordinary path sends the same types directly. Selector metadata, opt-in tracing, ownership checks, and result translation remain above that choice.

Objective-C exceptions normally stop inside the companion because an NSException cannot safely unwind through libffi and SBCL frames. The initial *objective-c-exception-policy* is :unchecked. Within an explicit caught scope, declaration/signature errors signal objective-c-bridge-error before the message send; native exceptions signal objective-c-exception with the message, receiver, selector, name, reason, and copied call stack. Neither condition fabricates a result.

#P6RUG7 makes the costly safety boundary explicit: with-objective-c-exception-handling dynamically selects caught sends for a diagnostic block. with-unchecked-objective-c-messages remains a composable spelling of the ordinary policy for nested scopes. An exception in direct code is a violated precondition and may terminate or cross the Lisp foreign-call stack; the first debugging move is to reproduce it under :catch.

Ownership remains explicit regardless of policy. Retaining a borrowed object returns a distinct owned wrapper for the same native identity; releasing a borrowed or released wrapper signals before sending release. with-owned-objective-c-object and with-autorelease-pool express the two ordinary scopes.

Apple framework calls also run with SBCL floating-point traps masked. The first cold device probe otherwise exposed FLOATING-POINT-OVERFLOW inside MTLCreateSystemDefaultDevice, matching the native environment already needed by SDL and Vulkan crossings.

Implementing Objective-C classes in Lisp remains a later capability for delegates and Cocoa integration. It is not required by the current Metal path because SDL owns application events and the first compiler path can be synchronous.

DONE Send one declared Objective-C message #3FW8Z5

Intent: establish the smallest inspectable Objective-C substrate without trying to bind an entire framework.

Evidence:

Done when (met): a declaration sends one typed message, ownership is explicit, opt-in traces are inspectable, redefinition remains live, and a fresh image exits cleanly.

DONE Turn Objective-C exceptions into Lisp conditions #H0UX9P

Intent: provide an explicit safe condition boundary so a diagnostic can catch an NSException without unwinding through CFFI or SBCL frames.

Evidence:

Done when (met): native exceptions are inspectable Lisp conditions, traces record them, the image remains usable, ABI errors precede sends, and the real framework path remains valid.

DONE Make Objective-C exception catching dynamically selectable #P6RUG7

Intent: keep exception translation available without imposing NSInvocation on every established framework call.

Evidence:

Done when (met): direct sending is the default, a block can dynamically opt into catching, tracing and both structure ABI directions still work, and the complete framework proof remains valid.

DONE Resolve the Objective-C dispatch entry once #WEE1DX

Intent: keep direct objc_msgSend direct by removing dynamic symbol lookup from every declared message.

Evidence:

Done when (met): no declared direct message performs symbol lookup, cold tests exercise the generated path, and the complete Metal frame remains visually and API-valid.

defvar *objective-c-message-send-pointer* runtime.lisp:339
defvar*objective-c-message-send-pointer*
cffi:foreign-symbol-pointer"objc_msgSend":library'objective-c-runtime-library

The stable libobjc dispatch entry, resolved once when this runtime loads.

Resolving this symbol for every declared message used to put dlsym on the per-frame path hundreds of times. #WEE1DX