SCUA

How-to

Describe and generate native bindings

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.

Start and bind a library · Native data · Callbacks · Lifetimes and unsafe access · Declarations and generation