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.
Mentioned in: The seams now meet in one Metal 4 draw
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:
- an
objective-c-classis a borrowed native class identity; - an internal definition structure retains selector, ABI, ownership, and result metadata for each declared Lisp function;
- an
objective-c-objectowns or borrows one pointer explicitly.
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.
Mentioned in: Objective-C interop is a reusable substrate
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:
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.
Mentioned in: Objective-C interop is a reusable substrate
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:
- direct CFFI calls handle C runtime and framework entry points;
- the default declared-message path calls
objc_msgSendthrough libffi; - an explicit diagnostic scope uses one native
NSInvocation@try/@catchbridge; and - small native helpers remain available for blocks, callbacks, or ABI forms not confidently expressible through CFFI.
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.
Mentioned in: Objective-C interop is a reusable substrate
DONE Send one declared Objective-C message #3FW8Z5
Intent: establish the smallest inspectable Objective-C substrate without trying to bind an entire framework.
Evidence:
- The runtime defines receivers, declaration metadata, direct sends, opt-in
trace snapshots, conditions, and
define-objective-c-message. - The Foundation vocabulary supplies explicit retain/release, owned scopes, autorelease pools, and string conversion.
- The first framework proof returns an owned
MTLDeviceand declares its borrowednameand scalarregistryIDmessages. - Cold tests cover metadata, native identity, ownership failures, released
objects, tracing, and a real device probe;
make objective-c-probeexits cleanly with bounded printable evidence. - Live re-evaluation replaces the definition and function while returning the same device registry identity.
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:
- The native bridge catches around
NSInvocation, validates declared argument/result sizes, and copies exception text and call-stack data before returning to Lisp. - The runtime signals
objective-c-exception; ABI disagreement signals the siblingobjective-c-bridge-errorbefore the message is sent. - Cold tests provoke a real
NSRangeException, observe a:signaledtrace event, then successfully send another message in the same image. A wrong result size is rejected before the message send. - The validated Metal clear still crosses object, scalar, and by-value structure messages and tears down cleanly.
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:
*objective-c-exception-policy*has the closed values:catchand:unchecked; direct sends are the default and the two public macros provide composable dynamic bindings.- Tests prove nested policy restoration and returned trace events across both
paths; Metal tests round-trip a by-value
CGSizeunchecked. - A real 480×320 luvcraft frame exposed the architectural cost: 801 generic invocation calls accounted for 0.950 seconds of 0.988 profiled seconds. The direct frame path subsequently submitted steady frames in about 17 ms.
- A complete unchecked SDL Metal clear passed Metal API validation, including
CGSizeandMTLClearColor, and destroyed all native presentation state. Production frame code uses the ordinary direct path.
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.
Mentioned in: The hybrid boundary is an explicit escape hatch
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:
- SBCL call profiling counted 160,400 calls to
objective-c-message-send-pointer, CFFI's symbol lookup, and SBCL's dynamic foreign-symbol resolver across 200 steady luvcraft frames: exactly 802 resolutions per frame. - Resolving the stable libobjc entry once reduced a 600-frame A/B run from 13.830 to 8.327 ms/frame and from approximately 497 to 249 KiB allocated per frame.
- The runtime now resolves
objc_msgSendwhen it loads and generated direct senders use that pointer. A cold test requires the pointer to be live and the former per-send resolver function to be absent. - The post-change 120-frame live Metal benchmark reported an 8.057 ms CPU median and 119.9 completion-limited frames/s. Metal API validation accepted the deterministic smoke frame, whose image hash remained unchanged.
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.
Mentioned in: Separate drawable pacing from encoding distributions
Referenced from code: 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. #WEE1DXdefvar *objective-c-message-send-pointer* runtime.lisp:339 ↗
(name (selector result-type &key ownership class consumes-receiver)
&body arguments)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
The native Metal backend is no longer only an architectural proposal. Luv can create a Metal GPU provider, attach it to an SDL-owned CAMetalLayer, compile two live mathematical shader methods through a device-owned MTL4Compiler, link them into MTLRenderPipelineState, bind a shared MTLBuffer by GPU address through…
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…
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: The definition site contains the ABI. The…
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: –…
Intent: keep exception translation available without imposing NSInvocation on every established framework call. Evidence: – *objective-c-exception-policy* has the closed values :catch and :unchecked; direct sends are the default and the two public macros provide composable dynamic bindings. – Tests prove nested policy…
Intent: keep direct objc_msgSend direct by removing dynamic symbol lookup from every declared message. Evidence: – SBCL call profiling counted 160,400 calls to objective-c-message-send-pointer, CFFI's symbol lookup, and SBCL's dynamic foreign-symbol resolver across 200 steady luvcraft frames: exactly 802 resolutions…