Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/adr/0102-explicit-bounded-ffi-call-descriptors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Explicit bounded FFI call descriptors

**Date:** 2026-07-24
**Area:** `ffi`, `runtime`
**Issues:** [#1029](https://github.com/frostney/GocciaScript/issues/1029), [#1030](https://github.com/frostney/GocciaScript/issues/1030), [#1031](https://github.com/frostney/GocciaScript/issues/1031), [#1032](https://github.com/frostney/GocciaScript/issues/1032), [#1033](https://github.com/frostney/GocciaScript/issues/1033)
**Related:** [ADR 0095](0095-custom-bidirectional-ffi-abi-engine.md)

GocciaScript extends its custom compiled-signature ABI engine rather than
adding a second foreign-call subsystem. Bound functions and callbacks may have
up to 64 arguments, and every top-level argument is independently classified,
including mixed `f32`, integer, pointer, and aggregate signatures. The bound
call and reverse-callback paths continue to consume the same immutable ABI
placements.

Variadic native functions use an explicit two-part contract. The signature
declares its ordinary fixed prefix with `variadic: true`. Each invocation then
supplies exactly one final `FFI.varargs(types, values)` marker. The marker's
parallel arrays must have equal lengths and the combined call must remain within
the 64-argument bound. Before native entry, the planner applies the C default
argument promotions and compiles the complete call signature. Target-specific
placement handles Windows x64 floating-point register duplication and Darwin
ARM64 variadic stack rules. Variadic callbacks are rejected.

Nullability is also explicit and compositional. `FFI.nullable("utf8string")`
accepts strict UTF-8/NUL-checked text or JavaScript `null`, which maps to native
`NULL`. Non-null text uses a temporary NUL-terminated UTF-8 buffer that is valid
only for the duration of the native call; native code must not retain the pointer
after the call returns. The descriptor is valid only in bound native-function
argument positions. Plain `utf8string` keeps its existing coercion behavior.
Other wrapped types, aggregate storage, returns, and callback positions are
rejected until their ownership and lifetime contracts are designed.

Aggregate definitions preserve exact native field names, including `buffer` and
`byteOffset`. Declared fields take precedence over the legacy direct metadata
properties. `FFI.metadata(value)` is the universal, non-colliding access path
for backing `buffer`, `byteOffset`, and `size`, plus `length` for arrays.
Non-colliding aggregate values retain their direct metadata properties.

Alternatives considered:

- **Adopt libffi for variadic and high-arity calls.** Rejected because the
existing custom planner already models all supported targets and is shared
with reverse callbacks. A second ABI engine would make behavior drift.
- **Infer variadic tails from extra JavaScript arguments.** Rejected because
JavaScript values do not carry the native type information required for ABI
classification and C default promotions.
- **Add more fixed-arity scalar dispatcher cases.** Rejected because the
compiled signature planner already supports register, stack, aggregate, and
hidden-return interactions without combinatorial dispatch tables.
- **Reserve aggregate metadata property names.** Rejected because native field
names must be represented exactly. A universal helper avoids collisions while
retaining compatible direct access where no collision exists.
- **Treat every pointer-like descriptor as nullable.** Rejected because string,
pointer, aggregate, and callback lifetimes have different ownership
contracts. The initial wrapper is deliberately limited to UTF-8 arguments.

Consequences:

- Each supported target must validate high-arity and variadic placement in its
CI lane.
- Variadic calls are explicit at both bind and invocation sites and cannot
silently accept an untyped tail.
- Future nullable types or positions require a separate lifetime and ownership
decision rather than inheriting `utf8string` behavior accidentally.
- Aggregate consumers that declare metadata-colliding fields must use
`FFI.metadata(value)` for backing-store inspection.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,4 @@ Durable architecture and implementation decisions for GocciaScript. New ADRs use
- [0099 — Canonical UTF-16 text with explicit encoded-byte boundaries](0099-canonical-utf16-text-boundaries.md)
- [0100 — Native binary64 execution with narrow semantic operations](0100-native-binary64-execution.md)
- [0101 — Closed numeric scalar self-call frames](0101-closed-numeric-scalar-self-call-frames.md)
- [0102 — Explicit bounded FFI call descriptors](0102-explicit-bounded-ffi-call-descriptors.md)
43 changes: 39 additions & 4 deletions docs/built-ins-ffi.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ The Foreign Function Interface calls native shared libraries. It is available on
| `FFI.union(fields)` | Declare a native-layout union type whose fields share storage. |
| `FFI.array(elementType, length)` | Declare a fixed-length inline array type. |
| `FFI.callback(signature)` | Declare a native callback type. `signature` uses the same `{ args, returns }` shape as `library.bind`. |
| `FFI.nullable("utf8string")` | Declare an explicit nullable UTF-8 string type for a bound native-function argument. |
| `FFI.varargs(types, values)` | Create the one explicit typed tail required by a variadic bound function call. |
| `FFI.metadata(value)` | Return aggregate backing metadata without conflicting with exact native field names. |
| `FFI.nullptr` | Singleton null pointer value |
| `FFI.suffix` | Platform library suffix (`.dylib`, `.so`, `.dll`) |

Expand All @@ -46,9 +49,9 @@ The Foreign Function Interface calls native shared libraries. It is available on

## Type Descriptors and Aggregate Values

Supported scalar types are `i8`, `i16`, `i32`, `i64`, `u8`, `u16`, `u32`, `u64`, `f32`, `f64`, `pointer`, `utf8string`, `bool`, and `void` (return only). `i64`/`u64` are not available on i386.
Supported scalar types are `i8`, `i16`, `i32`, `i64`, `u8`, `u16`, `u32`, `u64`, `f32`, `f64`, `pointer`, `utf8string`, `bool`, and `void` (return only). `i64`/`u64` are not available on i386. Top-level `f32` arguments may be mixed with integer, pointer, and aggregate arguments; the ABI planner assigns every argument independently.

`FFI.struct`, `FFI.union`, and `FFI.array` return compositional type descriptors. Descriptors expose `kind`, `size`, and `alignment`; array descriptors also expose `length`. Calling `descriptor.create(initializer)` returns an aggregate value backed by an `ArrayBuffer`. Structure and union fields use property access, arrays use numeric indices, and nested aggregate reads return views sharing the root buffer. Aggregate values expose `buffer`, `byteOffset`, and `size` (plus `length` for arrays). Detaching that buffer invalidates the root value and all nested views.
`FFI.struct`, `FFI.union`, and `FFI.array` return compositional type descriptors. Descriptors expose `kind`, `size`, and `alignment`; array descriptors also expose `length`. Calling `descriptor.create(initializer)` returns an aggregate value backed by an `ArrayBuffer`. Structure and union fields use property access, arrays use numeric indices, and nested aggregate reads return views sharing the root buffer. Aggregate values expose `buffer`, `byteOffset`, and `size` (plus `length` for arrays) when those names do not collide with declared fields. Exact native field names take precedence. `FFI.metadata(value)` always returns the backing `buffer`, `byteOffset`, and `size`, plus `length` for arrays. Detaching that buffer invalidates the root value and all nested views.

Layouts follow the current platform's native C ABI, including natural alignment, padding, register classification, indirect arguments, and hidden returns. Packed layouts, bitfields, custom alignment, non-native calling conventions, and bare C array-parameter decay are not modeled. Use `FFI.array` for inline array storage, such as an array field or the ABI shape of a wrapper aggregate.

Expand All @@ -75,6 +78,38 @@ result.point.x;

Pointer arguments accept `FFIPointer`, `ArrayBuffer`, `SharedArrayBuffer`, `TypedArray`, FFI aggregate values, persistent callback handles, or `null`.

## Nullable UTF-8 Arguments

Plain `utf8string` retains its existing JavaScript string-coercion behavior. Use `FFI.nullable("utf8string")` when JavaScript `null` must map to a native null pointer:

```javascript
const stringLength = library.bind("string_length", {
args: [FFI.nullable("utf8string")],
returns: "i32",
});

stringLength("hello"); // 5
stringLength(null); // native NULL
```

Non-null values must be strings containing well-formed UTF-16 without embedded NUL characters. GocciaScript encodes them into temporary NUL-terminated UTF-8 buffers that are valid only for the duration of the native call; native code must not retain those pointers after the call returns. Nullable descriptors currently support only `utf8string`, only in bound native-function argument positions. Aggregate storage, return types, and callback signatures reject them so the API does not imply unsupported ownership or lifetime semantics.

## Variadic Native Calls

Set `variadic: true` on a bound native-function signature and pass exactly one `FFI.varargs(types, values)` marker after the ordinary fixed arguments. The type and value arrays must have equal lengths. GocciaScript validates and plans the complete call before entering native code.

```javascript
const sum = library.bind("sum_values", {
args: ["i32"],
variadic: true,
returns: "i32",
});

sum(3, FFI.varargs(["i8", "i16", "i32"], [10, 20, 12]));
```

The variadic planner applies the C default argument promotions: `f32` becomes `f64`, while `bool`, `i8`, `i16`, `u8`, and `u16` become `i32`. Other supported argument descriptors retain their ordinary ABI classification. Variadic callbacks are not supported.

## Callbacks

`FFI.callback({ args, returns })` declares the ABI signature native code uses to call JavaScript. Passing a JavaScript callable directly to a callback-typed argument creates a call-scoped callback that is pinned for the native call and closed when that call returns. Call-scoped callbacks are non-escapable: native code must not retain or invoke their pointers after the native call returns. Use `CallbackType.create(callable)` when native code must retain the function pointer across calls; the returned persistent handle exposes `address`, `closed`, and an idempotent `close()` method. Native code must stop retaining the pointer before the handle is closed.
Expand Down Expand Up @@ -102,13 +137,13 @@ Callbacks may enter JavaScript only on the runtime thread that created them. A f

## Limits

- Function and callback signatures support at most 8 arguments.
- Function and callback signatures support at most 64 arguments. For variadic calls, this limit applies to the combined fixed prefix and explicit tail.
- At most 64 persistent or call-scoped callbacks may be active in the process at once. Closing a persistent handle or returning from a call-scoped callback releases its slot.
- Structures and unions support at most 256 fields, descriptor nesting is limited to 32 levels, and one aggregate may occupy at most 65,536 bytes.
- Bound function signatures cannot mix a top-level `f32` argument with other top-level argument types; use `f64` for mixed scalar signatures.
- FFI remains an unsafe runtime opt-in. Lifetime and thread guards prevent the documented engine-side hazards but cannot make arbitrary native code memory-safe.

## Related Documentation

- [Built-in Objects](built-ins.md) — index of core and runtime built-ins
- [ADR 0095](adr/0095-custom-bidirectional-ffi-abi-engine.md) — custom bidirectional ABI architecture and trade-offs
- [ADR 0102](adr/0102-explicit-bounded-ffi-call-descriptors.md) — explicit bounded high-arity, variadic, aggregate-metadata, and nullable-call contracts
170 changes: 169 additions & 1 deletion fixtures/ffi/fixture.c
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <stdlib.h>
#include <stddef.h>
#include <stdint.h>
#include <stdarg.h>

#if defined(_WIN32)
#include <windows.h>
Expand Down Expand Up @@ -74,6 +75,15 @@ float add_f32(float a, float b) {
return a + b;
}

float mixed_f32_i32(float value, int32_t scale, float offset) {
return value * scale + offset;
}

float mixed_f32_pointer(float value, const int32_t* scale, float offset) {
if (!scale) return offset;
return value * *scale + offset;
}

// -- Strings ----------------------------------------------------------------

int string_length(const char* s) {
Expand Down Expand Up @@ -114,12 +124,26 @@ int read_i32(const int* src) {
return *src;
}

// -- Multi-arg (up to 6) ---------------------------------------------------
// -- Multi-arg --------------------------------------------------------------

int sum6(int a, int b, int c, int d, int e, int f) {
return a + b + c + d + e + f;
}

int sum9(
int a,
int b,
int c,
int d,
int e,
int f,
int g,
int h,
int i
) {
return a + b + c + d + e + f + g + h + i;
}

double sum4_f64(double a, double b, double c, double d) {
return a + b + c + d;
}
Expand Down Expand Up @@ -154,13 +178,62 @@ double mixed8(int a, double b, int c, double d, int e, double f, int g, double h
return a + b + c + d + e + f + g + h;
}

// -- Variadic calls ---------------------------------------------------------

int32_t ffi_variadic_sum_i32(int32_t count, ...) {
int32_t result = 0;
int32_t index;
va_list args;

va_start(args, count);
for (index = 0; index < count; index++) {
result += va_arg(args, int);
}
va_end(args);
return result;
}

double ffi_variadic_promotions(const char* label, ...) {
int bool_value;
int narrow_value;
double float_value;
const char* text_value;
double result;
va_list args;

va_start(args, label);
bool_value = va_arg(args, int);
narrow_value = va_arg(args, int);
float_value = va_arg(args, double);
text_value = va_arg(args, const char*);
va_end(args);

result = (double)strlen(label);
result += bool_value;
result += narrow_value;
result += float_value;
result += (double)strlen(text_value);
return result;
}

// -- FFI v2 callbacks ------------------------------------------------------

typedef int32_t (*ffi_v2_i32_callback)(int32_t value);
typedef int8_t (*ffi_v2_i8_callback)(void);
typedef int16_t (*ffi_v2_i16_callback)(void);
typedef void (*ffi_v2_void_callback)(void);
typedef int (*ffi_v2_compare_i32_callback)(const void* left, const void* right);
typedef int32_t (*ffi_v2_sum9_callback)(
int32_t a,
int32_t b,
int32_t c,
int32_t d,
int32_t e,
int32_t f,
int32_t g,
int32_t h,
int32_t i
);

static ffi_v2_i32_callback ffi_v2_stored_i32_callback = NULL;
static int32_t ffi_v2_callback_progress = 0;
Expand Down Expand Up @@ -222,6 +295,10 @@ int32_t ffi_v2_get_callback_progress(void) {
return ffi_v2_callback_progress;
}

int32_t ffi_v2_call_sum9_callback(ffi_v2_sum9_callback callback) {
return callback(1, 2, 3, 4, 5, 6, 7, 8, 9);
}

// -- FFI v2 aggregate types ------------------------------------------------

typedef struct {
Expand Down Expand Up @@ -284,6 +361,52 @@ typedef struct {
uint8_t bytes[8192];
} ffi_v2_large_byte_array;

typedef struct {
int32_t buffer;
int32_t byteOffset;
} ffi_metadata_collision;

typedef struct {
float x;
float y;
} ffi_billboard_vector2;

typedef struct {
float x;
float y;
float z;
} ffi_billboard_vector3;

typedef struct {
float x;
float y;
float width;
float height;
} ffi_billboard_rectangle;

typedef struct {
ffi_billboard_vector3 position;
ffi_billboard_vector3 target;
ffi_billboard_vector3 up;
float fovy;
int32_t projection;
} ffi_billboard_camera3d;

typedef struct {
uint32_t id;
int32_t width;
int32_t height;
int32_t mipmaps;
int32_t format;
} ffi_billboard_texture2d;

typedef struct {
uint8_t r;
uint8_t g;
uint8_t b;
uint8_t a;
} ffi_billboard_color;

ffi_v2_point ffi_v2_add_points(ffi_v2_point left, ffi_v2_point right) {
ffi_v2_point result;
result.x = left.x + right.x;
Expand All @@ -297,6 +420,51 @@ double ffi_v2_point_distance_squared(ffi_v2_point left, ffi_v2_point right) {
return dx * dx + dy * dy;
}

float ffi_v2_mixed_point_f32(
float scale,
ffi_v2_point point,
float offset
) {
return scale * (float)(point.x + point.y) + offset;
}

double ffi_variadic_point_checksum(int32_t count, ...) {
double result = 0.0;
int32_t index;
va_list args;

va_start(args, count);
for (index = 0; index < count; index++) {
ffi_v2_point point = va_arg(args, ffi_v2_point);
result += point.x + point.y;
}
va_end(args);
return result;
}

int32_t ffi_metadata_collision_checksum(ffi_metadata_collision value) {
return value.buffer + value.byteOffset;
}

double ffi_draw_billboard_pro_checksum(
ffi_billboard_camera3d camera,
ffi_billboard_texture2d texture,
ffi_billboard_rectangle source,
ffi_billboard_vector3 position,
ffi_billboard_vector3 up,
ffi_billboard_vector2 size,
ffi_billboard_vector2 origin,
float rotation,
ffi_billboard_color tint
) {
return camera.position.x + camera.target.y + camera.up.z + camera.fovy +
camera.projection + texture.id + texture.width + texture.height +
texture.mipmaps + texture.format + source.x + source.y + source.width +
source.height + position.x + position.y + position.z + up.x + up.y + up.z +
size.x + size.y + origin.x + origin.y + rotation + tint.r + tint.g +
tint.b + tint.a;
}

void ffi_v2_translate_point(
ffi_v2_point* point,
double delta_x,
Expand Down
Loading
Loading