Declare native layouts and exported data, or generate a binding module from a reviewed manifest and C header. Use the headers and compiler options for the exact library you load.
These APIs are experimental and unreleased. Start with Native libraries and FFI for
setup and platform support. Code fragments below assume
import ffi, an open library handle, and C
functions with the signatures shown in comments. Symbol names are
illustrative unless linked to an executable example.
#Generated binding modules
Native declarations can also live in a versioned JSON manifest. Generate a SCUA module with:
python3 tools/ffi_bindgen.py examples/ffi_fixture.manifest.json --output examples/ffi_generated.scua
The module exports bind(library) and returns a table of
types, callable bindings, globals and constants. The host still grants
the library; generation grants no native access.
import ffi
import ffi_generated
let library = ffi.open("fixture")
let native = ffi_generated.bind(library)
print(ffi.call(native.add, 20, 22))
ffi.close(library)
The manifest declares named types, functions, exported data and constants. Function entries record the symbol, argument/result types and optional ownership, dependency and output-count contracts. The example manifest shows these together. A Clang-backed importer can create manifests from C headers:
python3 tools/ffi_import.py /path/to/library.h --discover-macros LIB_ --macro-report library.macros.json --output library.manifest.json
python3 tools/ffi_bindgen.py library.manifest.json --output library.scua
Review the generated manifest, especially return ownership and string conventions. Unsupported declarations are errors, not silently omitted bindings.
Macro discovery is prefix-scoped and bounded to 256 candidates. It imports supported integer, finite float/double and UTF-8 string constants without executing target code. Unsupported, function-like and empty macros are reported to stderr and the optional JSON report. Explicit macro selections override discovery and still fail on unsupported values. Review the report; discovery does not promise that every C macro can become a SCUA constant.
If runtime binding fails part-way, close its library before discarding the partial setup; type descriptors remain partition-scoped.
#Explicit record storage layouts
Explicit header storage-layout import also supports anonymous struct/union members. Promoted members use their C-visible names and compiler offsets. Union aliases share bytes: update the desired member individually, rather than treating aliases as independent fields. Pointer-bearing overlaps remain unsupported, and storage import alone does not enable by-value calls.
ffi.layout(fields, offsets, size, alignment[, names])
describes explicit record storage with explicit byte
offsets, size and alignment. For a packed byte followed by a uint64:
let Packed = ffi.layout(["u8", "u64"], [0, 1], 9, 1, ["tag", "value"])
let storage = ffi.buffer(Packed, 2)
ffi.set(storage, 0, {tag = 2, value = 40})
let read = ffi.bind(library, "read_packed", "u64",
[{type = ffi.pointer(Packed), count = 1, writable = false}])
print(ffi.call(read, storage))
ffi.close(storage)
Sizes are 1-65,536 bytes; alignments are powers of two through
65,536, and size must be a multiple of alignment. Every field must fit
within the declared extent. Fields may use pointer-free scalars, records
or fixed arrays. Unaligned fields use copying loads/stores; overlapping
fields alias their shared bytes. Named/indexed field access, buffer
indexing, borrowed pointer results and read-only enforcement use this
explicit layout. The caller must match the actual C layout.
Pointer-bearing layouts use
ffi.pack(library, Layout, values) rather than
ffi.buffer. They support the existing opaque/function
fields and fixed-extent, read-only typed pointer fields. Field ranges
must not overlap when any member carries pointers, so an alias cannot
overwrite pointer bytes behind the dependency tracker. Ordinary native
access remains read-only; SCUA's ordinary pointer-field replacement
follows the existing packed-record dependency and invalidation rules.
SCUA bitfield updates on these packed resources are transactional too:
range/allocation failure leaves bytes and existing views unchanged;
successful updates preserve pointer edges and invalidate old derived
views. Native writes and raw bit writes remain prohibited for
pointer-bearing storage.
ffi.array(Packed, count) retains the custom element's
exact storage stride and alignment. Nested fixed arrays work too,
including arrays embedded in another explicit layout. These arrays
remain storage-only, with a total size limit of 64 KiB; they do not gain
by-value ABI support.
These are storage-only descriptors, with no
fabricated provider aggregate type. Passing or returning one by value,
using one as a callback value, or embedding one in an ordinary by-value
aggregate is rejected unless an explicit ABI shape is supplied below.
Pass ffi.pointer(Packed) for storage-only layouts. Tracked
native mutation is described in resource
lifetimes. General packed and over-aligned by-value ABI support is
unavailable.
For named integer bitfields, use [byte, bit, width] in
place of a byte offset. Signedness comes from the declared integer type;
widths must fit that type and the record. For example:
let Bits = ffi.layout(["u32", "i32", "u32"],
[[0, 0, 3], [0, 3, 5], [1, 0, 12]], 4, 4, ["first", "second", "third"])
let values = ffi.buffer(Bits, 1)
ffi.set(values, 0, {first = 7, second = -16, third = 51})
ffi.field_set(values, 0, "second", 15) -- preserves first and third
ffi.close(values)
Bit numbering is least-significant first within each byte.
Out-of-range writes fail before changing bytes; whole-record writes also
validate in temporary storage. ffi.offsetof returns the
containing byte offset, not a C offsetof expression for a
bitfield. One-bit bool fields use SCUA booleans (integer
0/1 inputs are rejected). The importer omits unnamed padding fields,
including zero-width alignment barriers, while preserving their effects
on subsequent offsets. Anonymous aggregate fields still require an
explicit layout. Do not guess compiler bitfield packing: use
ffi_import.py --storage-layout 'struct Name' with the
library's header and compiler flags. It generates storage metadata and a
platform/architecture guard, not a by-value ABI adapter.
#Explicit ABI shape for custom records
An optional sixth ffi.layout argument declares a
provider aggregate for by-value calls:
let Bits = ffi.layout(["u32", "i32", "u32"],
[[0, 0, 3], [0, 3, 5], [1, 0, 12]], 4, 4,
["first", "second", "third"], ffi.struct(["u32"]))
Use this only when the provider aggregate is ABI-equivalent to the
actual C record on the target and calling convention. The example's
argument/result and callback round trips are tested on macOS
ARM64/x86-64 and Linux ARM64/x86-64. SCUA requires a pointer-free,
ABI-capable aggregate with exactly matching size and alignment, but
those checks do not prove register classification. A
wrong declaration can corrupt native calls, just like any incorrect FFI
signature. The importer accepts an explicit
--abi-shape 'struct Bits=struct Shape' mapping, with the
source also selected by --storage-layout. It checks
compiler size/alignment and identity-function lowering against the
ordinary shape record, but does not infer the shape or prove every
calling context equivalent. Packed records with incompatible
representations still need further ABI work.
The original fields continue to control SCUA encoding/decoding; the extra shape controls libffi's calling convention only. Without this explicit argument, custom layouts remain storage-only.
#Recursive native records
ffi.forward() creates an unresolved typed pointer. Use
it in read-only, fixed-extent record fields, then resolve it exactly
once with ffi.define(pointer, RecordType):
This also works with ffi.layout for packed recursive C
records. Storage offsets remain explicit; unresolved reachable pointers
are rejected by execution-facing APIs until definition completes.
let NodePointer = ffi.forward()
let Node = ffi.struct([
{type = NodePointer, count = 1, writable = false}, "i32"
], ["next", "value"])
ffi.define(NodePointer, Node)
-- C: struct Node { const struct Node *next; int32_t value; };
let nodes = ffi.bind(library, "nodes",
{type = NodePointer, count = 1, writable = false}, [])
let head = ffi.call(nodes)
let first = ffi.get(head, 0)
let second = ffi.get(first.next, 0)
Definition accepts a struct or union type, not a scalar or an inline
array. Multiple forward pointers may be resolved to mutually recursive
records. Native calls, buffer operations and layout queries reject any
unresolved pointer in the reachable type graph; declarations may be
assembled before all pointers are resolved. Redefinition is refused.
These descriptors may include function signatures and inline arrays of
function pointers that refer back to the record; creating those
descriptors does not permit native use before completion. These types
preserve ordinary bounded-view and dependency lifetimes: closing
head invalidates its decoded next views. C
still guarantees valid pointees; SCUA does not traverse the native list
automatically or infer the ownership of its nodes. The header importer
emits forward definitions for recursive record pointers when you supply
field extents, such as --pointer-field Node.next=1, and the
usual result/parameter extents. The manifest represents these with
"kind": "pointer", "forward": true, "element": "Node".
#Unions
ffi.union(member_types[, names]) declares a union of
supported scalar, opaque/function-pointer, struct, array or union
members. The members overlap at byte offset zero. Select the input
member explicitly with [member_index_or_name, value]:
let Number = ffi.union(["i64", "f64"], ["integer", "real"])
let echo = ffi.bind(library, "number_echo", Number, [Number])
let value = ffi.call(echo, ["real", 12.5])
print(value.real)
Without a member qualifier, pointer-free decoded results
contain all member interpretations: a named table if
names were declared, otherwise an array in declaration order. C supplies
no active-member tag; the binding/user must know which interpretation is
meaningful. Select a member again when passing a result back; the
decoded result is not an input selection. Unused input bytes are zeroed.
Native code must initialize the returned representation before it is
interpreted.
Fixed arrays can contain unions, including within by-value structs. Select a member on the array's element type when a single decoded interpretation is needed:
let Number = ffi.union(["i32", "f32"], ["integer", "real"])
let Pair = ffi.struct([ffi.array({type = Number, member = "integer"}, 2)], ["values"])
-- Input: [[["integer", 20], ["integer", 22]]]
-- Decoded result: result.values[0] is ["integer", value].
Bare POD unions in arrays expose each member interpretation on decode. Pointer-bearing union arrays require a selected element member; ambiguous results and input tags that disagree with the declared member are rejected. Top-level C array arguments still decay to pointers: use a typed pointer/span, not a by-value inline array declaration.
Pointer-bearing union results require an explicit member selection.
This prevents decoding unrelated overlapping bits into native handles. A
selected result is [member_name_or_index, value], and a
selected input declaration requires the same tag:
let Choice = ffi.union(["ptr", "f64"], ["pointer", "number"])
let PointerChoice = {type = Choice, member = "pointer"}
let echo_pointer = ffi.bind(library, "choice_echo", PointerChoice, [PointerChoice])
let result = ffi.call(echo_pointer, ["pointer", native_pointer])
-- result[0] is "pointer"; result[1] is a borrowed native handle.
The binding author must know which member C returns; SCUA cannot
infer the active member. For Clang-imported functions,
--union-result FUNCTION=MEMBER declares a top-level union
result selection. A named function-pointer typedef may replace FUNCTION
to qualify a callback result, for example
--union-result Callback=number. Use
--union-parameter Callback.0=pointer to select a callback's
first union input; FUNCTION may also name a normal exported function.
Use --union-field Record.choice=pointer for a union nested
in a named record field. The same annotation selects each union element
of a fixed or multidimensional array field. Different fields can select
different members without changing their shared C array typedef. That
selection applies to every use of the imported record descriptor; it
does not infer a changing native tag. To select differently for one
function, use a dotted member path:
--union-result get_pointer=choice.pointer or
--union-parameter echo.0=choice.number. Named callback
typedefs support the same paths. The importer creates separate qualified
record descriptors without changing the shared C type declaration. Paths
also apply through fixed arrays, selecting the same member for every
element. They do not traverse pointers or infer ownership. Select
multiple fields with comma-separated paths, for example
--union-result get=left.number,right.pointer. Up to sixteen
paths are supported. Conflicting selections of the same union are
rejected; any other pointer-bearing result unions still need record-wide
selections. For a typed pointer result, combine
--pointer-result get=1 with those same
--union-result get=left.number,right.pointer paths to
qualify the returned element record. The result is a bounded borrowed
read-only view. An explicit extent remains mandatory; paths do not
follow nested pointers or infer ownership. For the matching input, use
--pointer-parameter read.0=1 with
--union-parameter read.0=left.number,right.pointer.
Compatible declarations share the element descriptor, so the live
returned view can be passed directly to read.
Pointer-bearing union inputs require C const storage;
native pointer mutation is not enabled. Closed views remain invalid
inputs. Nested pointer-bearing unions must also select their decoded
members. Returned handles follow the same library and argument
dependencies as pointer-record fields. Callback union inputs likewise
require a decodable selection. Pointer-free unions may use this tagged
form too.
Native buffers and field_get/field_set work
with union members. Union by-value calls have focused macOS ARM64 and
x86-64 (Rosetta) evidence; see platform
support. Aggregate unions are classified recursively for the target
ABI. Pointer-bearing unions currently have macOS ARM64, x86-64/Rosetta,
Linux ARM64 and Linux x86-64 fixture evidence. General
packed/over-aligned by-value layouts remain unsupported; explicit
storage layouts support pointer-based access.
#Explicit bitfields
ffi.bits_get(buffer, byte_offset, bit_offset, width[, signed])
reads a field;
ffi.bits_set(buffer, byte_offset, bit_offset, width, value[, signed])
updates it. Byte offsets are zero-based, bit offsets are 0-7, and widths
are 1-64. Bit zero is the least-significant bit of its byte; successive
bits advance toward higher-address bytes. This is an explicit LSB-first
storage convention, not an inference of C compiler
packing. Use offsets established for your actual
compiler/target layout. Signed fields use two's-complement
interpretation; unsigned 64-bit values may use exact decimal strings.
Range/extent/read-only checks happen before mutation, and writes
preserve all neighbouring bits.
These operations work with native buffers or foreign views. These bit operations alone do not make a C bitfield record passable by value. Use compiler-derived storage layouts for header import; by-value calls require an explicitly compatible ABI shape.
#External constants and global variables
-- C: extern const int32_t VERSION; extern int32_t counter;
let version = ffi.constant(library, "VERSION", "i32")
let counter = ffi.global(library, "counter", "i32", true)
print(ffi.get(counter, 0))
ffi.set(counter, 0, 42)
ffi.close(counter)
ffi.constant copies an exported scalar or POD value. It
does not look up preprocessor macros or enum names: those are not
dynamic symbols. ffi.global creates a one-element typed
view of exported storage; an inline-array type describes an array
global. Omit its fourth argument for read-only access. Read-only views
reject all write helpers and mutable pointer arguments. Closing a view
never frees library storage. Library close/revocation invalidates its
views and their dependent handles before unloading. Copied constants
remain ordinary SCUA values.
The declared type and writable flag must match the real C object. Symbol lookup cannot prove the object size, mutability or even whether a symbol is data rather than code. C globals are process-wide: a grant does not isolate their state between partitions or synchronize other threads.
#Pointer-valued globals
cstr globals are copied on read, using the usual UTF-8,
nullability and one-MiB string limit. Read-only native arrays of string
pointers can use ffi.pointer("cstr") with a declared
count:
let Texts = ffi.pointer("cstr")
let names = ffi.bind(library, "get_names", {type = Texts, count = 3, writable = false}, [])
let native_names = ffi.call(names)
let first = ffi.get(native_names, 0) -- copied string, or nil for a nullable null slot
ffi.close(native_names) -- first remains usable
ffi.unsafe_view also supports read-only
opaque/function-pointer slots, qualified typed-pointer slots and
cstr slots. Pointer values retain the source view's
dependency; copied strings do not retain native addresses. This
read-only path does not permit writable pointer slots or transfer
ownership of each string. See tracked
and unsafe mutation for separate writable-view contracts. The native
declaration must promise valid, terminated readable strings. Header
import preserves const char *const * and requires result/
parameter extent annotations as for other typed pointer arrays.
For call-scoped input, a read-only ffi.pointer("cstr")
parameter with count or count_arg also accepts
a SCUA array of strings and nullable nil entries. For
example:
let length = ffi.bind(library, "string_array_length", "i32",
[{type = ffi.pointer("cstr"), count_arg = 1, writable = false}, "i32"])
print(ffi.call(length, ["hello", nil, "world"], 3))
SCUA copies the requested prefix and its pointer table, rejects
invalid UTF-8/embedded NULs, and adds a trailing null pointer. A zero
count passes a null base pointer. Each string is limited to one MiB,
each temporary span to 16 MiB, and all spans count against the native
memory budget. Copies are freed when the call returns or fails; C must
not modify, free or retain them. Native pointer/function/buffer results,
pointer-bearing record results and pointer-output slots are rejected for
these calls to avoid exporting temporary addresses. Copied
cstr results remain allowed. ffi.call_staged
uses the same copy-only string-input contract, with no string
copy-back.
Pointer-valued native globals can be read with
ffi.global(library, name, PtrType) and
ffi.get(view, 0), where PtrType is
"ptr", an opaque descriptor or a function-pointer
descriptor. The resulting pointer/function binding is borrowed and
expires when its view or library closes.
ffi.constant(library, name, PtrType) instead copies the
current pointer value into a borrowed handle tied directly to the
library, so it survives closing the temporary view. NULL obeys the
descriptor's nullability. Neither operation acquires native ownership:
the host must ensure that the pointee remains valid, including if native
code later updates the global. Overwriting a pointer global through this
read-only API is refused; a separately created tracked or explicitly
unsafe writable view has its own lifetime contract. Pointer globals
support get and bulk read.
Typed pointer globals require a fixed extent, for example
ffi.global(library, "samples", {type = ffi.pointer("f32"), count = 32, writable = false}).
Reading that global slot produces a bounded borrowed buffer view.
ffi.constant instead snapshots its pointer into a view tied
directly to the library. The writable type qualifier
controls mutation of the pointee bytes; it does not permit overwriting
the exported pointer slot. No allocation is copied or owned, and changes
to the native global do not retarget previously decoded views. The host
must keep those earlier allocations valid while views live.
Read-only pointer-containing record globals also support
get, read and field_get.
ffi.unsafe_view(pointer, RecordType, count) permits the
same operations over an explicitly declared native extent. Every decoded
pointer/function field depends on the view; closing its source pointer
or library invalidates the view and derived handles. As with other
unsafe views, the host must guarantee that the address and extent are
valid. Pass such a view to a native record-pointer input using
{type = ffi.pointer(RecordType), writable = false}.
Ordinary writable pointer-record views are rejected. Use the separate tracking
contract when checked native mutation is required.
ffi.constant on a record global copies borrowed fields tied
directly to the library, not the temporary global view. Bulk decoding
rolls back partial handle publication on failure; dedicated output slots
still require ffi.take.
#Process symbol namespace
A host can explicitly grant the process dynamic-symbol namespace instead of a library path:
scua_grant_ffi_process(partition, "process");
let process = ffi.open("process")
let abs = ffi.bind(process, "abs", "i32", ["i32"])
print(ffi.call(abs, -42))
ffi.close(process)
This is a broad trusted-code capability, not implied by ordinary
library grants. It uses the platform's dlopen(NULL, ...)
namespace. Only dynamically visible symbols are available; executable
symbols may require export linker flags. Lookup scope and ordering
follow the OS loader. The host must keep contributing native libraries
loaded while their symbols/resources are in use: a process handle does
not individually retain every dynamically loaded provider.
scua_revoke_ffi(partition, "process") invalidates its
bindings like any other grant. Closing the SCUA process handle does not
unload the executable. There is no new blanket CLI permission.
#Related guides
Start and bind a library · Native data · Callbacks · Lifetimes and unsafe access · Declarations and generation