From 609197741ad5293a86b9417a6f6aa93ab9cf6458 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Fri, 24 Jul 2026 09:16:38 +0100 Subject: [PATCH 1/3] feat(ffi): expand native call capabilities Support mixed f32 signatures, high-arity and variadic calls, collision-safe aggregate metadata, and nullable UTF-8 arguments. --- ...2-explicit-bounded-ffi-call-descriptors.md | 64 ++++++ docs/adr/README.md | 1 + docs/built-ins-ffi.md | 43 +++- fixtures/ffi/fixture.c | 170 ++++++++++++++- source/units/Goccia.Builtins.GlobalFFI.pas | 52 ++++- source/units/Goccia.Error.Messages.pas | 14 +- source/units/Goccia.FFI.ABI.pas | 68 +++++- source/units/Goccia.FFI.Types.pas | 152 +++---------- source/units/Goccia.Values.FFILibrary.pas | 148 ++++++++++--- source/units/Goccia.Values.FFIType.pas | 200 ++++++++++++++++-- tests/built-ins/FFI/ffi-v2-callback.js | 25 +++ tests/built-ins/FFI/ffi-v2-types.js | 70 +++++- tests/built-ins/FFI/prototype/arity.js | 88 ++++++++ tests/built-ins/FFI/prototype/bind.js | 22 ++ tests/built-ins/FFI/prototype/mixed.js | 31 ++- tests/built-ins/FFI/prototype/variadic.js | 110 ++++++++++ 16 files changed, 1065 insertions(+), 193 deletions(-) create mode 100644 docs/adr/0102-explicit-bounded-ffi-call-descriptors.md create mode 100644 tests/built-ins/FFI/prototype/arity.js create mode 100644 tests/built-ins/FFI/prototype/variadic.js diff --git a/docs/adr/0102-explicit-bounded-ffi-call-descriptors.md b/docs/adr/0102-explicit-bounded-ffi-call-descriptors.md new file mode 100644 index 000000000..9fec891ee --- /dev/null +++ b/docs/adr/0102-explicit-bounded-ffi-call-descriptors.md @@ -0,0 +1,64 @@ +# 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`. It 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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 65286c427..3fdd31bde 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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) diff --git a/docs/built-ins-ffi.md b/docs/built-ins-ffi.md index ea86d8f20..f314ac70d 100644 --- a/docs/built-ins-ffi.md +++ b/docs/built-ins-ffi.md @@ -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`) | @@ -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. @@ -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. 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. @@ -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 diff --git a/fixtures/ffi/fixture.c b/fixtures/ffi/fixture.c index a079805ec..010792422 100644 --- a/fixtures/ffi/fixture.c +++ b/fixtures/ffi/fixture.c @@ -7,6 +7,7 @@ #include #include #include +#include #if defined(_WIN32) #include @@ -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) { @@ -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; } @@ -154,6 +178,44 @@ 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); @@ -161,6 +223,17 @@ 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; @@ -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 { @@ -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; @@ -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, diff --git a/source/units/Goccia.Builtins.GlobalFFI.pas b/source/units/Goccia.Builtins.GlobalFFI.pas index 5190d650b..88f2857a3 100644 --- a/source/units/Goccia.Builtins.GlobalFFI.pas +++ b/source/units/Goccia.Builtins.GlobalFFI.pas @@ -23,6 +23,9 @@ TGocciaGlobalFFI = class(TGocciaBuiltin) function FFIUnion(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; function FFIArray(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; function FFICallback(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; + function FFINullable(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; + function FFIVarArgs(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; + function FFIMetadata(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; function FFINullptrGetter(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; function FFISuffixGetter(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; public @@ -40,12 +43,14 @@ implementation Goccia.Error.Messages, Goccia.Error.Suggestions, Goccia.FFI.DynamicLibrary, + Goccia.FFI.Types, Goccia.ThreadCleanupRegistry, Goccia.Values.ErrorHelper, Goccia.Values.FFILibrary, Goccia.Values.FFIPointer, Goccia.Values.FFIType, - Goccia.Values.ObjectPropertyDescriptor; + Goccia.Values.ObjectPropertyDescriptor, + Goccia.Values.ObjectValue; threadvar FStaticMembers: TArray; @@ -83,6 +88,9 @@ constructor TGocciaGlobalFFI.Create(const AName: string; Members.AddNamedMethod('union', FFIUnion, 1, gmkStaticMethod); Members.AddNamedMethod('array', FFIArray, 2, gmkStaticMethod); Members.AddNamedMethod('callback', FFICallback, 1, gmkStaticMethod); + Members.AddNamedMethod('nullable', FFINullable, 1, gmkStaticMethod); + Members.AddNamedMethod('varargs', FFIVarArgs, 2, gmkStaticMethod); + Members.AddNamedMethod('metadata', FFIMetadata, 1, gmkStaticMethod); Members.AddAccessor(PROP_NULLPTR, FFINullptrGetter, nil, [pfConfigurable], gmkStaticGetter); Members.AddAccessor(PROP_SUFFIX, FFISuffixGetter, nil, [pfConfigurable], gmkStaticGetter); FStaticMembers := Members.ToDefinitions; @@ -130,6 +138,48 @@ function TGocciaGlobalFFI.FFICallback(const AArgs: TGocciaArgumentsCollection; Result := CreateFFICallbackType(AArgs.GetElement(0)); end; +function TGocciaGlobalFFI.FFINullable( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +begin + if AArgs.Length < 1 then + ThrowTypeError(SErrorFFINullableRequiresType, SSuggestFFIUsage); + Result := CreateFFINullableType(AArgs.GetElement(0)); +end; + +function TGocciaGlobalFFI.FFIVarArgs( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +begin + if AArgs.Length < 2 then + ThrowTypeError(SErrorFFIVarArgsRequiresArrays, SSuggestFFIUsage); + Result := CreateFFIVarArgs(AArgs.GetElement(0), AArgs.GetElement(1)); +end; + +function TGocciaGlobalFFI.FFIMetadata( + const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +var + Aggregate: TGocciaFFIAggregateValue; + Metadata: TGocciaObjectValue; +begin + if (AArgs.Length < 1) or + not (AArgs.GetElement(0) is TGocciaFFIAggregateValue) then + ThrowTypeError(SErrorFFIMetadataRequiresAggregate, SSuggestFFIUsage); + Aggregate := TGocciaFFIAggregateValue(AArgs.GetElement(0)); + Aggregate.EnsureBackingStore; + Metadata := TGocciaObjectValue.Create; + Metadata.CreateDataPropertyOrThrow('buffer', Aggregate.Buffer); + Metadata.CreateDataPropertyOrThrow('byteOffset', + TGocciaNumberLiteralValue.Create(Aggregate.ByteOffset)); + Metadata.CreateDataPropertyOrThrow('size', + TGocciaNumberLiteralValue.Create(Aggregate.Descriptor.Size)); + if Aggregate.Descriptor.Kind = ftkArray then + Metadata.CreateDataPropertyOrThrow('length', + TGocciaNumberLiteralValue.Create(Aggregate.Descriptor.ElementCount)); + Result := Metadata; +end; + function TGocciaGlobalFFI.FFIOpen(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; var LibPath: string; diff --git a/source/units/Goccia.Error.Messages.pas b/source/units/Goccia.Error.Messages.pas index ff4775ced..3f5c4df68 100644 --- a/source/units/Goccia.Error.Messages.pas +++ b/source/units/Goccia.Error.Messages.pas @@ -387,16 +387,23 @@ interface SErrorFFIUnionRequiresDefinition = 'FFI.union requires a field definition object'; SErrorFFIArrayRequiresTypeAndLength = 'FFI.array requires an element type and length'; SErrorFFICallbackRequiresDefinition = 'FFI.callback requires a signature definition object'; + SErrorFFINullableRequiresType = 'FFI.nullable requires a wrapped type'; + SErrorFFINullableUTF8StringOnly = 'FFI.nullable currently supports only utf8string'; + SErrorFFINullableArgumentOnly = 'FFI nullable types are only valid as bound native-function arguments'; + SErrorFFIVarArgsRequiresArrays = 'FFI.varargs requires type and value arrays'; + SErrorFFIVarArgsArrays = 'FFI.varargs types and values must be arrays'; + SErrorFFIVarArgsLength = 'FFI.varargs types and values must have the same length'; + SErrorFFIMetadataRequiresAggregate = 'FFI.metadata requires an FFI aggregate value'; SErrorFFITypeDescriptorExpected = 'FFI type must be a type name or FFI type descriptor'; SErrorFFIVoidReturnOnly = 'void is only valid as an FFI return type'; SErrorFFI64BitType32Bit = 'i64/u64 FFI types are not supported on 32-bit platforms'; SErrorFFIAggregateDefinitionObject = 'FFI aggregate definition must be an object'; SErrorFFIAggregateDefinitionEmpty = 'FFI aggregate definition must contain at least one field'; - SErrorFFIAggregateFieldReserved = 'FFI aggregate field name is reserved: %s'; SErrorFFIArrayLengthPositive = 'FFI array length must be a positive integer'; SErrorFFICallbackDefinitionObject = 'FFI callback definition must be an object'; SErrorFFIMaxCallbackArguments = 'FFI supports a maximum of %d callback arguments'; SErrorFFICallbackArgsArray = 'FFI callback args must be an array'; + SErrorFFICallbackVariadicUnsupported = 'FFI callbacks cannot be variadic'; SErrorFFICallbackCreateCallable = 'FFI callback creation requires a callable value'; SErrorFFITypeCannotCreateValue = 'Only aggregate and callback FFI types can create values'; SErrorFFIAggregateFieldValue = 'FFI aggregate field requires a matching aggregate value'; @@ -411,13 +418,16 @@ interface SErrorFFICallbackArgumentValue = 'FFI callback argument requires a callable value or callback handle'; SErrorFFICallbackArgumentType = 'FFI callback argument type does not match signature'; SErrorFFIMaxArguments = 'FFI supports a maximum of %d arguments'; - SErrorFFIMixedF32Arguments = 'f32 arguments cannot be mixed with other types. Use f64 instead, or keep all arguments f32'; + SErrorFFIVariadicFlagBoolean = 'signature variadic must be a boolean'; + SErrorFFIVariadicTailRequired = 'Variadic FFI function %s requires one FFI.varargs(types, values) tail argument'; + SErrorFFIVariadicArgumentLimit = 'FFI function %s exceeds the maximum of %d total arguments'; SErrorFFICallbackAggregateReturn = 'FFI callback returned the wrong aggregate type'; SErrorFFICallbackHandleReturn = 'FFI callback return requires an FFICallback handle'; SErrorFFICallbackReturnType = 'FFI callback return type does not match signature'; SErrorFFICallbackPointerReturn = 'FFI callback pointer return requires FFIPointer or null'; SErrorFFICallbackUTF8StringReturn = 'FFI callback cannot return utf8string'; SErrorFFIUTF8StringArgument = 'FFI utf8string argument must be well-formed UTF-16 without embedded NUL'; + SErrorFFINullableUTF8StringArgument = 'FFI nullable utf8string argument must be a string or null and contain well-formed UTF-16 without embedded NUL'; SErrorFFIUTF8StringResult = 'FFI utf8string result contains invalid UTF-8'; SErrorFFICallbackRequiresCallable = 'FFICallback requires a callback type and callable value'; SErrorFFICallbackCloseReceiver = 'FFICallback.close requires an FFICallback'; diff --git a/source/units/Goccia.FFI.ABI.pas b/source/units/Goccia.FFI.ABI.pas index 9af74148d..7908f2524 100644 --- a/source/units/Goccia.FFI.ABI.pas +++ b/source/units/Goccia.FFI.ABI.pas @@ -50,12 +50,14 @@ TGocciaFFICompiledSignature = class FScratchSize: Integer; FGPRCount: Integer; FFPRCount: Integer; + FVariadicStartIndex: Integer; function GetArgumentCount: Integer; function GetArgument(const AIndex: Integer): TGocciaFFIArgumentPlan; public constructor Create(const AABI: TGocciaFFIABI; const AArguments: array of TGocciaFFITypeDescriptor; - const AReturnType: TGocciaFFITypeDescriptor); + const AReturnType: TGocciaFFITypeDescriptor; + const AVariadicStartIndex: Integer = -1); destructor Destroy; override; property ABI: TGocciaFFIABI read FABI; property ArgumentCount: Integer read GetArgumentCount; @@ -105,7 +107,7 @@ function TypeIsFloatScalar(const AType: TGocciaFFITypeDescriptor): Boolean; function TypeIsIntegerScalar(const AType: TGocciaFFITypeDescriptor): Boolean; begin - Result := (AType.Kind = ftkCallback) or + Result := (AType.Kind in [ftkCallback, ftkNullable]) or ((AType.Kind = ftkScalar) and not (AType.ScalarType in [fftVoid, fftF32, fftF64])); end; @@ -625,9 +627,10 @@ procedure CompileWin64Return(var APlan: TGocciaFFIReturnPlan; end; procedure CompileWin64Argument(var APlan: TGocciaFFIArgumentPlan; - var APosition, AStackOffset, AScratchOffset: Integer); + var APosition, AStackOffset, AScratchOffset: Integer; + const AVariadicFunction: Boolean); var - Placement: TGocciaFFIPlacement; + Placement, IntegerMirrorPlacement: TGocciaFFIPlacement; begin Placement.RegisterIndex := -1; Placement.StackOffset := -1; @@ -647,7 +650,9 @@ procedure CompileWin64Argument(var APlan: TGocciaFFIArgumentPlan; else Placement.Kind := fpkGPR; if APosition < 4 then + begin Placement.RegisterIndex := APosition + end else begin Placement.Kind := fpkStack; @@ -655,9 +660,46 @@ procedure CompileWin64Argument(var APlan: TGocciaFFIArgumentPlan; end; SetLength(APlan.Placements, 1); APlan.Placements[0] := Placement; + if AVariadicFunction and TypeIsFloatScalar(APlan.TypeDescriptor) and + (APosition < 4) then + begin + IntegerMirrorPlacement := Placement; + IntegerMirrorPlacement.Kind := fpkGPR; + SetLength(APlan.Placements, 2); + APlan.Placements[1] := IntegerMirrorPlacement; + end; Inc(APosition); end; +procedure CompileDarwinARM64VariadicArgument( + var APlan: TGocciaFFIArgumentPlan; + var AStackOffset, AScratchOffset: Integer); +var + Placement: TGocciaFFIPlacement; +begin + Placement.Kind := fpkStack; + Placement.RegisterIndex := -1; + Placement.ValueOffset := 0; + if APlan.TypeDescriptor.IsAggregate and + (APlan.TypeDescriptor.Size > 16) then + begin + APlan.Indirect := True; + APlan.IndirectCopyOffset := ScratchAppend(AScratchOffset, + APlan.TypeDescriptor.Size, 16); + Placement.Size := SizeOf(Pointer); + Placement.StackOffset := StackAppend(AStackOffset, SizeOf(Pointer), + SizeOf(Pointer), 8); + end + else + begin + Placement.Size := APlan.TypeDescriptor.Size; + Placement.StackOffset := StackAppend(AStackOffset, + APlan.TypeDescriptor.Size, APlan.TypeDescriptor.Alignment, 8); + end; + SetLength(APlan.Placements, 1); + APlan.Placements[0] := Placement; +end; + procedure CompileI386Return(var APlan: TGocciaFFIReturnPlan; var AStackOffset: Integer); begin @@ -702,16 +744,21 @@ procedure CompileI386Argument(var APlan: TGocciaFFIArgumentPlan; constructor TGocciaFFICompiledSignature.Create(const AABI: TGocciaFFIABI; const AArguments: array of TGocciaFFITypeDescriptor; - const AReturnType: TGocciaFFITypeDescriptor); + const AReturnType: TGocciaFFITypeDescriptor; + const AVariadicStartIndex: Integer); var I, GPRCount, FPRCount, Position, StackOffset, ScratchOffset: Integer; begin inherited Create; if Length(AArguments) > MAX_FFI_ARGS then raise EArgumentException.Create('FFI signature has too many arguments'); + if (AVariadicStartIndex < -1) or + (AVariadicStartIndex > Length(AArguments)) then + raise EArgumentException.Create('FFI variadic argument index is invalid'); if not Assigned(AReturnType) then raise EArgumentException.Create('FFI signature return type is missing'); FABI := AABI; + FVariadicStartIndex := AVariadicStartIndex; FReturnPlan.TypeDescriptor := AReturnType; AReturnType.AddReference; SetLength(FArguments, Length(AArguments)); @@ -742,13 +789,18 @@ constructor TGocciaFFICompiledSignature.Create(const AABI: TGocciaFFIABI; CompileSysVArgument(FArguments[I], GPRCount, FPRCount, StackOffset); fabiWin64: CompileWin64Argument(FArguments[I], Position, StackOffset, - ScratchOffset); + ScratchOffset, FVariadicStartIndex >= 0); fabiAAPCS64: CompileARM64Argument(FArguments[I], GPRCount, FPRCount, StackOffset, ScratchOffset, False); fabiDarwinARM64: - CompileARM64Argument(FArguments[I], GPRCount, FPRCount, StackOffset, - ScratchOffset, True); + if (FVariadicStartIndex >= 0) and + (I >= FVariadicStartIndex) then + CompileDarwinARM64VariadicArgument(FArguments[I], StackOffset, + ScratchOffset) + else + CompileARM64Argument(FArguments[I], GPRCount, FPRCount, StackOffset, + ScratchOffset, True); fabiI386Win: CompileI386Argument(FArguments[I], StackOffset); end; diff --git a/source/units/Goccia.FFI.Types.pas b/source/units/Goccia.FFI.Types.pas index dcda5a678..c1f7ddf5c 100644 --- a/source/units/Goccia.FFI.Types.pas +++ b/source/units/Goccia.FFI.Types.pas @@ -23,7 +23,8 @@ interface ftkStruct, ftkUnion, ftkArray, - ftkCallback + ftkCallback, + ftkNullable ); TGocciaFFITypeDescriptor = class; @@ -61,6 +62,8 @@ TGocciaFFITypeDescriptor = class class function CreateCallback( const AArguments: array of TGocciaFFITypeDescriptor; const AReturnType: TGocciaFFITypeDescriptor): TGocciaFFITypeDescriptor; + class function CreateNullable( + const AElementType: TGocciaFFITypeDescriptor): TGocciaFFITypeDescriptor; procedure AddReference; procedure ReleaseReference; function IsAggregate: Boolean; {$IFDEF FPC}inline;{$ENDIF} @@ -84,22 +87,6 @@ TGocciaFFITypeDescriptor = class TGocciaFFIArgClass = (facInteger, facSingle, facDouble, facMixed); TGocciaFFIReturnClass = (frcVoid, frcInteger, frcSingle, frcDouble); - TGocciaFFISlot = record - case Integer of - 0: (AsInt: NativeInt); - 1: (AsSingle: Single); - 2: (AsDouble: Double); - end; - - TGocciaFFISignature = record - ArgTypes: array of TGocciaFFIType; - ReturnType: TGocciaFFIType; - ArgCount: Integer; - ArgClass: TGocciaFFIArgClass; - ArgBitmask: Integer; - ReturnClass: TGocciaFFIReturnClass; - end; - TGocciaFFIResult = record case Integer of 0: (AsInt: NativeInt); @@ -108,7 +95,7 @@ TGocciaFFIResult = record end; const - MAX_FFI_ARGS = 8; + MAX_FFI_ARGS = 64; MAX_FFI_FIELDS = 256; MAX_FFI_DESCRIPTOR_DEPTH = 32; MAX_FFI_AGGREGATE_SIZE = 65536; @@ -131,9 +118,6 @@ TGocciaFFIResult = record function ParseFFIType(const AName: string): TGocciaFFIType; function FFITypeName(const AType: TGocciaFFIType): string; function AlignFFIOffset(const AOffset, AAlignment: Integer): Integer; -function FFITypeToArgClass(const AType: TGocciaFFIType): TGocciaFFIArgClass; -function FFITypeToReturnClass(const AType: TGocciaFFIType): TGocciaFFIReturnClass; -function ValidateSignature(var ASignature: TGocciaFFISignature): string; implementation @@ -366,6 +350,32 @@ class function TGocciaFFITypeDescriptor.CreateCallback( end; end; +class function TGocciaFFITypeDescriptor.CreateNullable( + const AElementType: TGocciaFFITypeDescriptor): TGocciaFFITypeDescriptor; +begin + if not Assigned(AElementType) then + raise EArgumentException.Create('FFI nullable element type is missing'); + if (AElementType.Kind <> ftkScalar) or + (AElementType.ScalarType <> fftUTF8String) then + raise EArgumentException.Create( + 'FFI nullable currently supports only utf8string'); + Result := TGocciaFFITypeDescriptor.Create; + try + Result.FKind := ftkNullable; + Result.FElementType := AElementType; + Result.FElementType.AddReference; + Result.FSize := AElementType.Size; + Result.FAlignment := AElementType.Alignment; + Result.FDepth := AElementType.Depth + 1; + if Result.FDepth > MAX_FFI_DESCRIPTOR_DEPTH then + raise EArgumentOutOfRangeException.Create( + 'FFI descriptor nesting exceeds limit'); + except + Result.Free; + raise; + end; +end; + procedure TGocciaFFITypeDescriptor.AddReference; begin Inc(FReferenceCount); @@ -470,104 +480,4 @@ function FFITypeName(const AType: TGocciaFFIType): string; end; end; -function FFITypeToArgClass(const AType: TGocciaFFIType): TGocciaFFIArgClass; -begin - case AType of - fftF32: Result := facSingle; - fftF64: Result := facDouble; - else - Result := facInteger; - end; -end; - -function FFITypeToReturnClass(const AType: TGocciaFFIType): TGocciaFFIReturnClass; -begin - case AType of - fftVoid: Result := frcVoid; - fftF32: Result := frcSingle; - fftF64: Result := frcDouble; - else - Result := frcInteger; - end; -end; - -function ValidateSignature(var ASignature: TGocciaFFISignature): string; -var - I: Integer; - HasInteger, HasSingle, HasDouble: Boolean; - Bitmask: Integer; -begin - Result := ''; - ASignature.ArgBitmask := 0; - - if ASignature.ArgCount > MAX_FFI_ARGS then - begin - Result := 'FFI supports a maximum of ' + IntToStr(MAX_FFI_ARGS) + ' arguments'; - Exit; - end; - - {$IF not (defined(GOCCIA_CPU_64))} - for I := 0 to ASignature.ArgCount - 1 do - if ASignature.ArgTypes[I] in [fftI64, fftU64] then - begin - Result := 'i64/u64 argument types are not supported on 32-bit platforms'; - Exit; - end; - if ASignature.ReturnType in [fftI64, fftU64] then - begin - Result := 'i64/u64 return types are not supported on 32-bit platforms'; - Exit; - end; - {$ENDIF} - - ASignature.ReturnClass := FFITypeToReturnClass(ASignature.ReturnType); - - if ASignature.ArgCount = 0 then - begin - ASignature.ArgClass := facInteger; - Exit; - end; - - HasInteger := False; - HasSingle := False; - HasDouble := False; - for I := 0 to ASignature.ArgCount - 1 do - case FFITypeToArgClass(ASignature.ArgTypes[I]) of - facInteger: HasInteger := True; - facSingle: HasSingle := True; - facDouble: HasDouble := True; - end; - - if HasInteger and not HasSingle and not HasDouble then - begin - ASignature.ArgClass := facInteger; - Exit; - end; - if HasSingle and not HasInteger and not HasDouble then - begin - ASignature.ArgClass := facSingle; - Exit; - end; - if HasDouble and not HasInteger and not HasSingle then - begin - ASignature.ArgClass := facDouble; - Exit; - end; - - if HasSingle then - begin - Result := 'f32 arguments cannot be mixed with other types. Use f64 instead, or keep all arguments f32'; - Exit; - end; - - // Mixed integer + double — supported via assembly trampolines on all platforms - Bitmask := 0; - for I := 0 to ASignature.ArgCount - 1 do - if FFITypeToArgClass(ASignature.ArgTypes[I]) = facDouble then - Bitmask := Bitmask or (1 shl I); - - ASignature.ArgClass := facMixed; - ASignature.ArgBitmask := Bitmask; -end; - end. diff --git a/source/units/Goccia.Values.FFILibrary.pas b/source/units/Goccia.Values.FFILibrary.pas index 2c6f0f54b..b99750d53 100644 --- a/source/units/Goccia.Values.FFILibrary.pas +++ b/source/units/Goccia.Values.FFILibrary.pas @@ -94,24 +94,28 @@ TGocciaFFIBoundFunctionValue = class(TGocciaNativeFunctionValue) FSignature: TGocciaFFICompiledSignature; FName: string; FLibraryGuard: TGocciaFFILibraryGuard; + FVariadic: Boolean; function Invoke(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; public constructor Create(const ASymbol: Pointer; const ASignature: TGocciaFFICompiledSignature; const AName: string; - const ALibraryGuard: TGocciaFFILibraryGuard); + const ALibraryGuard: TGocciaFFILibraryGuard; + const AVariadic: Boolean); destructor Destroy; override; end; constructor TGocciaFFIBoundFunctionValue.Create(const ASymbol: Pointer; const ASignature: TGocciaFFICompiledSignature; const AName: string; - const ALibraryGuard: TGocciaFFILibraryGuard); + const ALibraryGuard: TGocciaFFILibraryGuard; + const AVariadic: Boolean); begin inherited CreateWithoutPrototype(Invoke, AName, ASignature.ArgumentCount); FSymbol := ASymbol; FSignature := ASignature; FName := AName; + FVariadic := AVariadic; ALibraryGuard.RetainDependent; FLibraryGuard := ALibraryGuard; end; @@ -190,6 +194,22 @@ function MarshalFFIValue(const AType: TGocciaFFITypeDescriptor; SetLength(Result, AType.Size); if Length(Result) > 0 then FillChar(Result[0], Length(Result), 0); + if AType.Kind = ftkNullable then + begin + if AValue is TGocciaNullLiteralValue then + PointerValue := nil + else + begin + if not (AValue is TGocciaStringLiteralValue) or + not TryEncodeFFIUTF8String( + TGocciaStringLiteralValue(AValue).Value, ATemporaryString) then + ThrowTypeError(SErrorFFINullableUTF8StringArgument, + SSuggestFFIUsage); + PointerValue := @ATemporaryString[0]; + end; + Move(PointerValue, Result[0], SizeOf(Pointer)); + Exit; + end; if AType.IsAggregate then begin if not (AValue is TGocciaFFIAggregateValue) then @@ -386,6 +406,20 @@ function UnmarshalFFIValue(const AType: TGocciaFFITypeDescriptor; end; end; +function PromoteVariadicType( + const AType: TGocciaFFITypeDescriptor): TGocciaFFITypeDescriptor; +begin + if AType.Kind = ftkScalar then + case AType.ScalarType of + fftBool, fftI8, fftI16, fftU8, fftU16: + Exit(TGocciaFFITypeDescriptor.CreateScalar(fftI32)); + fftF32: + Exit(TGocciaFFITypeDescriptor.CreateScalar(fftF64)); + end; + Result := AType; + Result.AddReference; +end; + function TGocciaFFIBoundFunctionValue.Invoke( const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; @@ -395,26 +429,79 @@ function TGocciaFFIBoundFunctionValue.Invoke( TemporaryStrings: array of TBytes; Callbacks: array of TGocciaFFICallbackValue; TemporaryCallbacks: array of Boolean; + CombinedTypes: array of TGocciaFFITypeDescriptor; + Signature: TGocciaFFICompiledSignature; + CallSignature: TGocciaFFICompiledSignature; + VarArgs: TGocciaFFIVarArgsValue; + ArgumentValue: TGocciaValue; CallContext: TGocciaFFICallContext; CallContextActive: Boolean; - I: Integer; + I, FixedCount, TotalCount: Integer; begin if FLibraryGuard.IsClosed then ThrowTypeError(Format(SErrorFFICallLibraryClosed, [FName]), SSuggestFFIUsage); - if AArgs.Length < FSignature.ArgumentCount then + FixedCount := FSignature.ArgumentCount; + if AArgs.Length < FixedCount then ThrowTypeError(Format(SErrorFFIFuncArgCount, - [FName, FSignature.ArgumentCount, AArgs.Length]), SSuggestFFIUsage); + [FName, FixedCount, AArgs.Length]), SSuggestFFIUsage); - SetLength(NativeArguments, FSignature.ArgumentCount); - SetLength(TemporaryStrings, FSignature.ArgumentCount); - SetLength(Callbacks, FSignature.ArgumentCount); - SetLength(TemporaryCallbacks, FSignature.ArgumentCount); + CallSignature := nil; + VarArgs := nil; + Signature := FSignature; + TotalCount := FixedCount; + if FVariadic then + begin + if (AArgs.Length <> FixedCount + 1) or + not (AArgs.GetElement(FixedCount) is TGocciaFFIVarArgsValue) then + ThrowTypeError(Format(SErrorFFIVariadicTailRequired, [FName]), + SSuggestFFIUsage); + VarArgs := TGocciaFFIVarArgsValue(AArgs.GetElement(FixedCount)); + TotalCount := FixedCount + VarArgs.Count; + if TotalCount > MAX_FFI_ARGS then + ThrowRangeError(Format(SErrorFFIVariadicArgumentLimit, + [FName, MAX_FFI_ARGS]), SSuggestFFIUsage); + SetLength(CombinedTypes, TotalCount); + for I := 0 to FixedCount - 1 do + CombinedTypes[I] := FSignature.Arguments[I].TypeDescriptor; + try + for I := 0 to VarArgs.Count - 1 do + CombinedTypes[FixedCount + I] := + PromoteVariadicType(VarArgs.TypeAt(I)); + try + CallSignature := TGocciaFFICompiledSignature.Create(CurrentFFIABI, + CombinedTypes, FSignature.ReturnPlan.TypeDescriptor, FixedCount); + except + on E: EArgumentOutOfRangeException do + ThrowRangeError(SErrorFFICallLayoutLimit, SSuggestFFIUsage); + on E: EArgumentException do + ThrowTypeError(SErrorFFIInvalidCompiledSignature, + SSuggestFFIUsage); + end; + finally + for I := FixedCount to High(CombinedTypes) do + if Assigned(CombinedTypes[I]) then + CombinedTypes[I].ReleaseReference; + end; + Signature := CallSignature; + end; + + SetLength(NativeArguments, TotalCount); + SetLength(TemporaryStrings, TotalCount); + SetLength(Callbacks, TotalCount); + SetLength(TemporaryCallbacks, TotalCount); + CallContextActive := False; try - for I := 0 to FSignature.ArgumentCount - 1 do + for I := 0 to TotalCount - 1 do + begin + if I < FixedCount then + ArgumentValue := AArgs.GetElement(I) + else + ArgumentValue := VarArgs.ValueAt(I - FixedCount); NativeArguments[I] := MarshalFFIValue( - FSignature.Arguments[I].TypeDescriptor, AArgs.GetElement(I), I, + Signature.Arguments[I].TypeDescriptor, ArgumentValue, I, TemporaryStrings[I], Callbacks[I], TemporaryCallbacks[I]); + end; if FLibraryGuard.IsClosed then ThrowTypeError(Format(SErrorFFICallLibraryClosed, [FName]), SSuggestFFIUsage); @@ -423,7 +510,7 @@ function TGocciaFFIBoundFunctionValue.Invoke( CallContextActive := True; try try - FFIInvokeCompiled(FSymbol, FSignature, NativeArguments, NativeResult); + FFIInvokeCompiled(FSymbol, Signature, NativeArguments, NativeResult); finally try FinishFFICallContext(CallContext); @@ -436,7 +523,7 @@ function TGocciaFFIBoundFunctionValue.Invoke( SSuggestFFIUsage); for I := 0 to High(Callbacks) do if Assigned(Callbacks[I]) then Callbacks[I].EnsureOpen; - Result := UnmarshalFFIValue(FSignature.ReturnPlan.TypeDescriptor, + Result := UnmarshalFFIValue(Signature.ReturnPlan.TypeDescriptor, NativeResult, FLibraryGuard); finally if CallContextActive then CancelFFICallContext(CallContext); @@ -445,6 +532,7 @@ function TGocciaFFIBoundFunctionValue.Invoke( for I := 0 to High(Callbacks) do if TemporaryCallbacks[I] and Assigned(Callbacks[I]) then Callbacks[I].CloseForFFICallCleanup; + CallSignature.Free; end; end; @@ -552,15 +640,14 @@ procedure TGocciaFFILibraryValue.MarkReferences; // -- Prototype methods ------------------------------------------------------ function ParseSignatureFromArgs(const AArgs: TGocciaArgumentsCollection; - const AFuncName: string): TGocciaFFICompiledSignature; + const AFuncName: string; out AVariadic: Boolean): TGocciaFFICompiledSignature; var SigObj: TGocciaObjectValue; - ArgsField, ReturnsField: TGocciaValue; + ArgsField, ReturnsField, VariadicField: TGocciaValue; ArgsArray: TGocciaArrayValue; ArgumentTypes: array of TGocciaFFITypeDescriptor; ReturnType: TGocciaFFITypeDescriptor; I, J: Integer; - HasF32, HasOtherArgumentType: Boolean; begin if AArgs.Length < 2 then ThrowTypeError(SErrorFFIBindRequiresNameAndSig, SSuggestFFIUsage); @@ -569,6 +656,7 @@ function ParseSignatureFromArgs(const AArgs: TGocciaArgumentsCollection; ThrowTypeError(SErrorFFIBindSigObject, SSuggestFFIUsage); SigObj := TGocciaObjectValue(AArgs.GetElement(1)); + AVariadic := False; ReturnType := nil; try ArgsField := SigObj.GetProperty('args'); @@ -581,17 +669,7 @@ function ParseSignatureFromArgs(const AArgs: TGocciaArgumentsCollection; SetLength(ArgumentTypes, ArgsArray.Elements.Count); for I := 0 to ArgsArray.Elements.Count - 1 do ArgumentTypes[I] := ParseFFITypeDescriptorValue( - ArgsArray.Elements[I], False); - HasF32 := False; - HasOtherArgumentType := False; - for I := 0 to High(ArgumentTypes) do - if (ArgumentTypes[I].Kind = ftkScalar) and - (ArgumentTypes[I].ScalarType = fftF32) then - HasF32 := True - else - HasOtherArgumentType := True; - if HasF32 and HasOtherArgumentType then - ThrowTypeError(SErrorFFIMixedF32Arguments, SSuggestFFIUsage); + ArgsArray.Elements[I], ftpBoundArgument); end else if (ArgsField = nil) or (ArgsField is TGocciaUndefinedLiteralValue) then @@ -604,7 +682,16 @@ function ParseSignatureFromArgs(const AArgs: TGocciaArgumentsCollection; (ReturnsField is TGocciaUndefinedLiteralValue) then ReturnType := TGocciaFFITypeDescriptor.CreateScalar(fftVoid) else - ReturnType := ParseFFITypeDescriptorValue(ReturnsField, True); + ReturnType := ParseFFITypeDescriptorValue(ReturnsField, + ftpBoundReturn); + VariadicField := SigObj.GetProperty('variadic'); + if Assigned(VariadicField) and + not (VariadicField is TGocciaUndefinedLiteralValue) then + begin + if not (VariadicField is TGocciaBooleanLiteralValue) then + ThrowTypeError(SErrorFFIVariadicFlagBoolean, SSuggestFFIUsage); + AVariadic := TGocciaBooleanLiteralValue(VariadicField).Value; + end; try Result := TGocciaFFICompiledSignature.Create(CurrentFFIABI, ArgumentTypes, ReturnType); @@ -629,6 +716,7 @@ function TGocciaFFILibraryValue.Bind(const AArgs: TGocciaArgumentsCollection; co FuncName: string; Sig: TGocciaFFICompiledSignature; SymbolPtr: Pointer; + Variadic: Boolean; begin if not (AThisValue is TGocciaFFILibraryValue) then ThrowTypeError(SErrorFFIBindRequiresLibrary, SSuggestFFILibraryOpen); @@ -638,7 +726,7 @@ function TGocciaFFILibraryValue.Bind(const AArgs: TGocciaArgumentsCollection; co ThrowTypeError(SErrorFFIBindLibraryClosed, SSuggestFFILibraryOpen); FuncName := AArgs.GetElement(0).ToStringLiteral.Value; - Sig := ParseSignatureFromArgs(AArgs, FuncName); + Sig := ParseSignatureFromArgs(AArgs, FuncName, Variadic); try try @@ -648,7 +736,7 @@ function TGocciaFFILibraryValue.Bind(const AArgs: TGocciaArgumentsCollection; co ThrowTypeError(E.Message, SSuggestFFIUsage); end; Result := TGocciaFFIBoundFunctionValue.Create(SymbolPtr, Sig, FuncName, - Lib.FLibraryGuard); + Lib.FLibraryGuard, Variadic); except Sig.Free; raise; diff --git a/source/units/Goccia.Values.FFIType.pas b/source/units/Goccia.Values.FFIType.pas index 4e1756db4..f74702827 100644 --- a/source/units/Goccia.Values.FFIType.pas +++ b/source/units/Goccia.Values.FFIType.pas @@ -13,6 +13,14 @@ interface Goccia.Values.Primitives; type + TGocciaFFITypePosition = ( + ftpBoundArgument, + ftpBoundReturn, + ftpAggregateStorage, + ftpCallbackArgument, + ftpCallbackReturn + ); + TGocciaFFITypeDescriptorValue = class(TGocciaObjectValue) private FDescriptor: TGocciaFFITypeDescriptor; @@ -31,6 +39,21 @@ TGocciaFFITypeDescriptorValue = class(TGocciaObjectValue) property Descriptor: TGocciaFFITypeDescriptor read FDescriptor; end; + TGocciaFFIVarArgsValue = class(TGocciaObjectValue) + private + FTypes: array of TGocciaFFITypeDescriptor; + FValues: array of TGocciaValue; + public + constructor Create(const ATypes: array of TGocciaFFITypeDescriptor; + const AValues: array of TGocciaValue); + destructor Destroy; override; + function Count: Integer; {$IFDEF FPC}inline;{$ENDIF} + function TypeAt(const AIndex: Integer): TGocciaFFITypeDescriptor; + function ValueAt(const AIndex: Integer): TGocciaValue; + function ToStringTag: string; override; + procedure MarkReferences; override; + end; + TGocciaFFIAggregatePointerGuardEntry = record Offset: Integer; LifetimeGuard: TGocciaFFIDependentGuard; @@ -96,11 +119,14 @@ TGocciaFFIAggregateValue = class(TGocciaObjectValue) end; function ParseFFITypeDescriptorValue(const AValue: TGocciaValue; - const AAllowVoid: Boolean): TGocciaFFITypeDescriptor; + const APosition: TGocciaFFITypePosition): TGocciaFFITypeDescriptor; function CreateFFIStructType(const ADefinition: TGocciaValue): TGocciaValue; function CreateFFIUnionType(const ADefinition: TGocciaValue): TGocciaValue; function CreateFFIArrayType(const AElementValue, ALengthValue: TGocciaValue): TGocciaValue; function CreateFFICallbackType(const ADefinition: TGocciaValue): TGocciaValue; +function CreateFFINullableType(const AElementValue: TGocciaValue): TGocciaValue; +function CreateFFIVarArgs(const ATypesValue, + AValuesValue: TGocciaValue): TGocciaValue; implementation @@ -121,6 +147,7 @@ implementation const FFI_TYPE_DESCRIPTOR_TAG = 'FFIType'; FFI_AGGREGATE_TAG = 'FFIAggregate'; + FFI_VARARGS_TAG = 'FFIVarArgs'; PROP_CREATE = 'create'; PROP_KIND = 'kind'; PROP_SIZE = 'size'; @@ -129,6 +156,69 @@ implementation PROP_BYTE_OFFSET = 'byteOffset'; PROP_LENGTH = 'length'; +constructor TGocciaFFIVarArgsValue.Create( + const ATypes: array of TGocciaFFITypeDescriptor; + const AValues: array of TGocciaValue); +var + I: Integer; +begin + inherited Create(TGocciaObjectValue.SharedObjectPrototype); + if Length(ATypes) <> Length(AValues) then + raise EArgumentException.Create( + 'FFI variadic type and value counts do not match'); + SetLength(FTypes, Length(ATypes)); + SetLength(FValues, Length(AValues)); + for I := 0 to High(ATypes) do + begin + FTypes[I] := ATypes[I]; + FTypes[I].AddReference; + FValues[I] := AValues[I]; + end; +end; + +destructor TGocciaFFIVarArgsValue.Destroy; +var + I: Integer; +begin + for I := 0 to High(FTypes) do + if Assigned(FTypes[I]) then + FTypes[I].ReleaseReference; + inherited; +end; + +function TGocciaFFIVarArgsValue.Count: Integer; +begin + Result := Length(FTypes); +end; + +function TGocciaFFIVarArgsValue.TypeAt( + const AIndex: Integer): TGocciaFFITypeDescriptor; +begin + Result := FTypes[AIndex]; +end; + +function TGocciaFFIVarArgsValue.ValueAt( + const AIndex: Integer): TGocciaValue; +begin + Result := FValues[AIndex]; +end; + +function TGocciaFFIVarArgsValue.ToStringTag: string; +begin + Result := FFI_VARARGS_TAG; +end; + +procedure TGocciaFFIVarArgsValue.MarkReferences; +var + I: Integer; +begin + if GCMarked then Exit; + inherited; + for I := 0 to High(FValues) do + if Assigned(FValues[I]) then + FValues[I].MarkReferences; +end; + constructor TGocciaFFIAggregatePointerGuards.Create; begin inherited; @@ -280,6 +370,7 @@ function TypeKindName(const AKind: TGocciaFFITypeKind): string; ftkUnion: Result := 'union'; ftkArray: Result := 'array'; ftkCallback: Result := 'callback'; + ftkNullable: Result := 'nullable'; else Result := 'unknown'; end; @@ -309,8 +400,20 @@ function ParseCanonicalIndex(const AName: string; out AIndex: Integer): Boolean; (IntToStr(AIndex) = AName); end; +procedure ValidateFFITypePosition(const AType: TGocciaFFITypeDescriptor; + const APosition: TGocciaFFITypePosition); +begin + if (AType.Kind = ftkScalar) and + (AType.ScalarType = fftVoid) and + not (APosition in [ftpBoundReturn, ftpCallbackReturn]) then + ThrowTypeError(SErrorFFIVoidReturnOnly, SSuggestFFIUsage); + if (AType.Kind = ftkNullable) and + (APosition <> ftpBoundArgument) then + ThrowTypeError(SErrorFFINullableArgumentOnly, SSuggestFFIUsage); +end; + function ParseFFITypeDescriptorValue(const AValue: TGocciaValue; - const AAllowVoid: Boolean): TGocciaFFITypeDescriptor; + const APosition: TGocciaFFITypePosition): TGocciaFFITypeDescriptor; var TypeName: string; ScalarType: TGocciaFFIType; @@ -319,6 +422,12 @@ function ParseFFITypeDescriptorValue(const AValue: TGocciaValue; begin Result := TGocciaFFITypeDescriptorValue(AValue).Descriptor; Result.AddReference; + try + ValidateFFITypePosition(Result, APosition); + except + Result.ReleaseReference; + raise; + end; Exit; end; @@ -330,14 +439,69 @@ function ParseFFITypeDescriptorValue(const AValue: TGocciaValue; ScalarType := ParseFFIType(TypeName); if (ScalarType = fftVoid) and (TypeName <> FFI_TYPE_VOID) then ThrowTypeError(Format(SErrorFFIUnknownType, [TypeName]), SSuggestFFIUsage); - if (ScalarType = fftVoid) and not AAllowVoid then - ThrowTypeError(SErrorFFIVoidReturnOnly, - SSuggestFFIUsage); {$IF not (defined(GOCCIA_CPU_64))} if ScalarType in [fftI64, fftU64] then ThrowTypeError(SErrorFFI64BitType32Bit, SSuggestFFIUsage); {$ENDIF} Result := TGocciaFFITypeDescriptor.CreateScalar(ScalarType); + try + ValidateFFITypePosition(Result, APosition); + except + Result.ReleaseReference; + raise; + end; +end; + +function CreateFFINullableType(const AElementValue: TGocciaValue): TGocciaValue; +var + ElementType, Descriptor: TGocciaFFITypeDescriptor; +begin + if not (AElementValue is TGocciaStringLiteralValue) or + (TGocciaStringLiteralValue(AElementValue).Value <> + FFI_TYPE_UTF8STRING) then + ThrowTypeError(SErrorFFINullableUTF8StringOnly, SSuggestFFIUsage); + ElementType := TGocciaFFITypeDescriptor.CreateScalar(fftUTF8String); + try + Descriptor := TGocciaFFITypeDescriptor.CreateNullable(ElementType); + finally + ElementType.ReleaseReference; + end; + Result := TGocciaFFITypeDescriptorValue.Create(Descriptor, True); +end; + +function CreateFFIVarArgs(const ATypesValue, + AValuesValue: TGocciaValue): TGocciaValue; +var + TypesArray, ValuesArray: TGocciaArrayValue; + Types: array of TGocciaFFITypeDescriptor; + Values: array of TGocciaValue; + I, J: Integer; +begin + if not (ATypesValue is TGocciaArrayValue) or + not (AValuesValue is TGocciaArrayValue) then + ThrowTypeError(SErrorFFIVarArgsArrays, SSuggestFFIUsage); + TypesArray := TGocciaArrayValue(ATypesValue); + ValuesArray := TGocciaArrayValue(AValuesValue); + if TypesArray.Elements.Count <> ValuesArray.Elements.Count then + ThrowTypeError(SErrorFFIVarArgsLength, SSuggestFFIUsage); + if TypesArray.Elements.Count > MAX_FFI_ARGS then + ThrowTypeError(Format(SErrorFFIMaxArguments, [MAX_FFI_ARGS]), + SSuggestFFIUsage); + SetLength(Types, TypesArray.Elements.Count); + SetLength(Values, ValuesArray.Elements.Count); + try + for I := 0 to TypesArray.Elements.Count - 1 do + begin + Types[I] := ParseFFITypeDescriptorValue(TypesArray.Elements[I], + ftpBoundArgument); + Values[I] := ValuesArray.Elements[I]; + end; + Result := TGocciaFFIVarArgsValue.Create(Types, Values); + finally + for J := 0 to High(Types) do + if Assigned(Types[J]) then + Types[J].ReleaseReference; + end; end; function CreateAggregateType(const ADefinition: TGocciaValue; @@ -363,13 +527,9 @@ function CreateAggregateType(const ADefinition: TGocciaValue; try for I := 0 to High(FieldNames) do begin - if (FieldNames[I] = PROP_BUFFER) or - (FieldNames[I] = PROP_BYTE_OFFSET) then - ThrowTypeError(Format(SErrorFFIAggregateFieldReserved, - [FieldNames[I]]), SSuggestFFIUsage); Fields[I].Name := FieldNames[I]; Fields[I].TypeDescriptor := ParseFFITypeDescriptorValue( - DefinitionObject.GetProperty(FieldNames[I]), False); + DefinitionObject.GetProperty(FieldNames[I]), ftpAggregateStorage); end; try if AKind = ftkStruct then @@ -414,7 +574,8 @@ function CreateFFIArrayType(const AElementValue, ThrowTypeError(SErrorFFIArrayLengthPositive, SSuggestFFIUsage); ElementCount := Trunc(LengthValue); - ElementType := ParseFFITypeDescriptorValue(AElementValue, False); + ElementType := ParseFFITypeDescriptorValue(AElementValue, + ftpAggregateStorage); try try Descriptor := TGocciaFFITypeDescriptor.CreateArray(ElementType, @@ -431,7 +592,7 @@ function CreateFFIArrayType(const AElementValue, function CreateFFICallbackType(const ADefinition: TGocciaValue): TGocciaValue; var DefinitionObject: TGocciaObjectValue; - ArgumentsValue, ReturnValue: TGocciaValue; + ArgumentsValue, ReturnValue, VariadicValue: TGocciaValue; ArgumentsArray: TGocciaArrayValue; ArgumentTypes: array of TGocciaFFITypeDescriptor; ReturnType, Descriptor: TGocciaFFITypeDescriptor; @@ -442,6 +603,16 @@ function CreateFFICallbackType(const ADefinition: TGocciaValue): TGocciaValue; ThrowTypeError(SErrorFFICallbackDefinitionObject, SSuggestFFIUsage); DefinitionObject := TGocciaObjectValue(ADefinition); + VariadicValue := DefinitionObject.GetProperty('variadic'); + if Assigned(VariadicValue) and + not (VariadicValue is TGocciaUndefinedLiteralValue) then + begin + if not (VariadicValue is TGocciaBooleanLiteralValue) then + ThrowTypeError(SErrorFFIVariadicFlagBoolean, SSuggestFFIUsage); + if TGocciaBooleanLiteralValue(VariadicValue).Value then + ThrowTypeError(SErrorFFICallbackVariadicUnsupported, + SSuggestFFIUsage); + end; ReturnType := nil; try @@ -455,7 +626,7 @@ function CreateFFICallbackType(const ADefinition: TGocciaValue): TGocciaValue; SetLength(ArgumentTypes, ArgumentsArray.Elements.Count); for I := 0 to ArgumentsArray.Elements.Count - 1 do ArgumentTypes[I] := ParseFFITypeDescriptorValue( - ArgumentsArray.Elements[I], False); + ArgumentsArray.Elements[I], ftpCallbackArgument); end else if not (ArgumentsValue is TGocciaUndefinedLiteralValue) then ThrowTypeError(SErrorFFICallbackArgsArray, SSuggestFFIUsage); @@ -464,7 +635,8 @@ function CreateFFICallbackType(const ADefinition: TGocciaValue): TGocciaValue; if ReturnValue is TGocciaUndefinedLiteralValue then ReturnType := TGocciaFFITypeDescriptor.CreateScalar(fftVoid) else - ReturnType := ParseFFITypeDescriptorValue(ReturnValue, True); + ReturnType := ParseFFITypeDescriptorValue(ReturnValue, + ftpCallbackReturn); try Descriptor := TGocciaFFITypeDescriptor.CreateCallback(ArgumentTypes, ReturnType); diff --git a/tests/built-ins/FFI/ffi-v2-callback.js b/tests/built-ins/FFI/ffi-v2-callback.js index 1950f9bc8..1ee1da97a 100644 --- a/tests/built-ins/FFI/ffi-v2-callback.js +++ b/tests/built-ins/FFI/ffi-v2-callback.js @@ -48,6 +48,20 @@ describe("FFI.callback", () => { ], returns: "f64", }); + const Sum9Callback = FFI.callback({ + args: [ + "i32", + "i32", + "i32", + "i32", + "i32", + "i32", + "i32", + "i32", + "i32", + ], + returns: "i32", + }); afterAll(() => lib.close()); @@ -83,6 +97,17 @@ describe("FFI.callback", () => { expect(invoke((value) => add(value, 5), 37)).toBe(42); }); + test("passes more than eight arguments through reverse callbacks", () => { + const invoke = lib.bind("ffi_v2_call_sum9_callback", { + args: [Sum9Callback], + returns: "i32", + }); + + expect( + invoke((a, b, c, d, e, f, g, h, i) => a + b + c + d + e + f + g + h + i), + ).toBe(45); + }); + test("sign-extends signed narrow scalar arguments and callback returns", () => { const signedI8 = lib.bind("ffi_v2_signed_i8_to_i32", { args: ["i8"], diff --git a/tests/built-ins/FFI/ffi-v2-types.js b/tests/built-ins/FFI/ffi-v2-types.js index ffc616137..c86401d25 100644 --- a/tests/built-ins/FFI/ffi-v2-types.js +++ b/tests/built-ins/FFI/ffi-v2-types.js @@ -3,19 +3,73 @@ describe("FFI type descriptors", () => { expect(() => FFI.struct({})).toThrow(TypeError); expect(() => FFI.union({})).toThrow(TypeError); expect(() => FFI.struct({ invalid: "void" })).toThrow(TypeError); - expect(() => FFI.struct({ buffer: "u8" })).toThrow(TypeError); - expect(() => FFI.union({ byteOffset: "u8" })).toThrow(TypeError); expect(() => FFI.array("u8", 0)).toThrow(TypeError); expect(() => FFI.array("u8", 1.5)).toThrow(TypeError); expect(() => FFI.callback({ args: "i32", returns: "i32" })).toThrow( TypeError, ); - expect(() => - FFI.callback({ - args: ["i32", "i32", "i32", "i32", "i32", "i32", "i32", "i32", "i32"], - returns: "i32", - }), - ).toThrow(TypeError); + }); + + test("prioritizes exact native field names over direct metadata properties", () => { + const Collision = FFI.struct({ buffer: "i32", byteOffset: "i32" }); + const collision = Collision.create({ buffer: 17, byteOffset: 25 }); + const lib = FFI.open("./fixtures/ffi/libfixture" + FFI.suffix); + const checksum = lib.bind("ffi_metadata_collision_checksum", { + args: [Collision], + returns: "i32", + }); + const metadata = FFI.metadata(collision); + const Bytes = FFI.array("u8", 4); + const bytes = Bytes.create([1, 2, 3, 4]); + const bytesMetadata = FFI.metadata(bytes); + + try { + expect(collision.buffer).toBe(17); + expect(collision.byteOffset).toBe(25); + expect(checksum(collision)).toBe(42); + expect(metadata.buffer).toBeInstanceOf(ArrayBuffer); + expect(metadata.byteOffset).toBe(0); + expect(metadata.size).toBe(8); + expect(bytesMetadata.buffer).toBe(bytes.buffer); + expect(bytesMetadata.byteOffset).toBe(0); + expect(bytesMetadata.size).toBe(4); + expect(bytesMetadata.length).toBe(4); + expect(() => FFI.metadata({})).toThrow(TypeError); + metadata.buffer.transfer(); + expect(() => FFI.metadata(collision)).toThrow(TypeError); + } finally { + lib.close(); + } + }); + + test("limits nullable descriptors to bound utf8string arguments", () => { + const NullableUTF8 = FFI.nullable("utf8string"); + const FixedCallback = FFI.callback({ + args: [], + variadic: false, + returns: "void", + }); + const lib = FFI.open("./fixtures/ffi/libfixture" + FFI.suffix); + + try { + expect(FixedCallback.kind).toBe("callback"); + expect(() => FFI.nullable("pointer")).toThrow(TypeError); + expect(() => FFI.struct({ value: NullableUTF8 })).toThrow(TypeError); + expect(() => + FFI.callback({ args: [NullableUTF8], returns: "void" }), + ).toThrow(TypeError); + expect(() => + FFI.callback({ args: [], returns: NullableUTF8 }), + ).toThrow(TypeError); + expect(() => + FFI.callback({ args: [], variadic: true, returns: "void" }), + ).toThrow(TypeError); + expect(() => + lib.bind("greeting", { args: [], returns: NullableUTF8 }), + ).toThrow(TypeError); + } finally { + lib.close(); + } }); test("bounds aggregate size and descriptor nesting", () => { diff --git a/tests/built-ins/FFI/prototype/arity.js b/tests/built-ins/FFI/prototype/arity.js new file mode 100644 index 000000000..2cdae99fd --- /dev/null +++ b/tests/built-ins/FFI/prototype/arity.js @@ -0,0 +1,88 @@ +describe("FFILibrary.prototype.bind (high-arity signatures)", () => { + const lib = FFI.open("./fixtures/ffi/libfixture" + FFI.suffix); + afterAll(() => lib.close()); + + test("calls a function with more than eight scalar arguments", () => { + const sum9 = lib.bind("sum9", { + args: [ + "i32", + "i32", + "i32", + "i32", + "i32", + "i32", + "i32", + "i32", + "i32", + ], + returns: "i32", + }); + + expect(sum9(1, 2, 3, 4, 5, 6, 7, 8, 9)).toBe(45); + }); + + test("calls a nine-argument DrawBillboardPro-shaped signature", () => { + const Vector2 = FFI.struct({ x: "f32", y: "f32" }); + const Vector3 = FFI.struct({ x: "f32", y: "f32", z: "f32" }); + const Rectangle = FFI.struct({ + x: "f32", + y: "f32", + width: "f32", + height: "f32", + }); + const Camera3D = FFI.struct({ + position: Vector3, + target: Vector3, + up: Vector3, + fovy: "f32", + projection: "i32", + }); + const Texture2D = FFI.struct({ + id: "u32", + width: "i32", + height: "i32", + mipmaps: "i32", + format: "i32", + }); + const Color = FFI.struct({ r: "u8", g: "u8", b: "u8", a: "u8" }); + const checksum = lib.bind("ffi_draw_billboard_pro_checksum", { + args: [ + Camera3D, + Texture2D, + Rectangle, + Vector3, + Vector3, + Vector2, + Vector2, + "f32", + Color, + ], + returns: "f64", + }); + + const camera = Camera3D.create({ + position: Vector3.create({ x: 1, y: 0, z: 0 }), + target: Vector3.create({ x: 0, y: 2, z: 0 }), + up: Vector3.create({ x: 0, y: 0, z: 3 }), + fovy: 4, + projection: 5, + }); + const texture = Texture2D.create({ + id: 6, + width: 7, + height: 8, + mipmaps: 9, + format: 10, + }); + const source = Rectangle.create({ x: 11, y: 12, width: 13, height: 14 }); + const position = Vector3.create({ x: 15, y: 16, z: 17 }); + const up = Vector3.create({ x: 18, y: 19, z: 20 }); + const size = Vector2.create({ x: 21, y: 22 }); + const origin = Vector2.create({ x: 23, y: 24 }); + const tint = Color.create({ r: 26, g: 27, b: 28, a: 29 }); + + expect( + checksum(camera, texture, source, position, up, size, origin, 25, tint), + ).toBe(435); + }); +}); diff --git a/tests/built-ins/FFI/prototype/bind.js b/tests/built-ins/FFI/prototype/bind.js index 39f7517b5..d2cfab81b 100644 --- a/tests/built-ins/FFI/prototype/bind.js +++ b/tests/built-ins/FFI/prototype/bind.js @@ -61,6 +61,28 @@ describe("FFILibrary.prototype.bind", () => { expect(strlen("h\u00e9\u{1F600}")).toBe(7); }); + test("binds a nullable utf8string argument explicitly", () => { + const NullableUTF8 = FFI.nullable("utf8string"); + const strlen = lib.bind("string_length", { + args: [NullableUTF8], + returns: "i32", + }); + + expect(strlen("h\u00e9\u{1F600}")).toBe(7); + expect(strlen(null)).toBe(-1); + expect(() => strlen("\uD800")).toThrow(TypeError); + expect(() => strlen("before\0after")).toThrow(TypeError); + }); + + test("keeps plain utf8string null coercion compatible", () => { + const strlen = lib.bind("string_length", { + args: ["utf8string"], + returns: "i32", + }); + + expect(strlen(null)).toBe(4); + }); + test("binds and calls a function returning utf8string", () => { const greet = lib.bind("greeting", { args: [], returns: "utf8string" }); expect(greet()).toBe("hello from C"); diff --git a/tests/built-ins/FFI/prototype/mixed.js b/tests/built-ins/FFI/prototype/mixed.js index b0f4ad9ef..ae7daa14d 100644 --- a/tests/built-ins/FFI/prototype/mixed.js +++ b/tests/built-ins/FFI/prototype/mixed.js @@ -63,9 +63,32 @@ describe("FFILibrary.prototype.bind (mixed int/float signatures)", () => { expect(m8(1, 2.0, 3, 4.0, 5, 6.0, 7, 8.0)).toBe(36); }); - test("f32 mixing still throws", () => { - expect(() => - lib.bind("get_answer", { args: ["i32", "f32"], returns: "i32" }) - ).toThrow(TypeError); + test("calls mixed top-level f32 and integer arguments", () => { + const mixed = lib.bind("mixed_f32_i32", { + args: ["f32", "i32", "f32"], + returns: "f32", + }); + + expect(mixed(2.5, 4, 0.5)).toBe(10.5); + }); + + test("calls mixed top-level f32 and pointer arguments", () => { + const mixed = lib.bind("mixed_f32_pointer", { + args: ["f32", "pointer", "f32"], + returns: "f32", + }); + const scale = new Int32Array([4]); + + expect(mixed(2.5, scale, 0.5)).toBe(10.5); + }); + + test("calls mixed top-level f32 and aggregate arguments", () => { + const Point = FFI.struct({ x: "f64", y: "f64" }); + const mixed = lib.bind("ffi_v2_mixed_point_f32", { + args: ["f32", Point, "f32"], + returns: "f32", + }); + + expect(mixed(2, Point.create({ x: 3, y: 4 }), 0.5)).toBe(14.5); }); }); diff --git a/tests/built-ins/FFI/prototype/variadic.js b/tests/built-ins/FFI/prototype/variadic.js new file mode 100644 index 000000000..054472069 --- /dev/null +++ b/tests/built-ins/FFI/prototype/variadic.js @@ -0,0 +1,110 @@ +describe("FFILibrary.prototype.bind (variadic signatures)", () => { + const lib = FFI.open("./fixtures/ffi/libfixture" + FFI.suffix); + afterAll(() => lib.close()); + + test("calls an integer variadic function through an explicit typed tail", () => { + const sum = lib.bind("ffi_variadic_sum_i32", { + args: ["i32"], + variadic: true, + returns: "i32", + }); + + expect( + sum( + 5, + FFI.varargs( + ["i8", "u8", "i16", "u16", "bool"], + [-1, 2, -3, 4, true], + ), + ), + ).toBe(3); + }); + + test("applies C default argument promotions to the variadic tail", () => { + const checksum = lib.bind("ffi_variadic_promotions", { + args: ["utf8string"], + variadic: true, + returns: "f64", + }); + + expect( + checksum( + "four", + FFI.varargs( + ["bool", "i8", "f32", "utf8string"], + [true, 3, 2.5, "six"], + ), + ), + ).toBe(13.5); + }); + + test("classifies aggregate values in a variadic tail", () => { + const Point = FFI.struct({ x: "f64", y: "f64" }); + const OtherPoint = FFI.struct({ x: "f64", y: "f64" }); + const checksum = lib.bind("ffi_variadic_point_checksum", { + args: ["i32"], + variadic: true, + returns: "f64", + }); + + expect( + checksum( + 2, + FFI.varargs( + [Point, Point], + [ + Point.create({ x: 1, y: 2 }), + Point.create({ x: 10, y: 20 }), + ], + ), + ), + ).toBe(33); + expect(() => + checksum( + 1, + FFI.varargs( + [Point], + [OtherPoint.create({ x: 1, y: 2 })], + ), + ), + ).toThrow(TypeError); + }); + + test("validates the explicit variadic marker before native entry", () => { + const sum = lib.bind("ffi_variadic_sum_i32", { + args: ["i32"], + variadic: true, + returns: "i32", + }); + + expect(() => sum(1)).toThrow(TypeError); + expect(() => sum(2, 1, 2)).toThrow(TypeError); + expect(() => sum(1, ["i32"], [1])).toThrow(TypeError); + expect(() => FFI.varargs(["i32"], [])).toThrow(TypeError); + expect(() => FFI.varargs("i32", [1])).toThrow(TypeError); + expect(() => + lib.bind("ffi_variadic_sum_i32", { + args: ["i32"], + variadic: 1, + returns: "i32", + }), + ).toThrow(TypeError); + }); + + test("bounds the combined fixed and variadic argument count", () => { + const sum = lib.bind("ffi_variadic_sum_i32", { + args: ["i32"], + variadic: true, + returns: "i32", + }); + const types = []; + const values = []; + + for (const unused of new Array(64)) { + types.push("i32"); + values.push(1); + } + + expect(() => sum(64, FFI.varargs(types, values))).toThrow(RangeError); + }); +}); From 0e9f3809ff4c7587d79e50b67ed80b19ee28b7fa Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Fri, 24 Jul 2026 12:30:20 +0100 Subject: [PATCH 2/3] test(ffi): cover cross-ABI call planning --- source/units/Goccia.FFI.ABI.Test.pas | 212 ++++++++++++++++++++++ tests/built-ins/FFI/ffi-v2-types.js | 12 ++ tests/built-ins/FFI/prototype/bind.js | 1 + tests/built-ins/FFI/prototype/variadic.js | 1 + 4 files changed, 226 insertions(+) create mode 100644 source/units/Goccia.FFI.ABI.Test.pas diff --git a/source/units/Goccia.FFI.ABI.Test.pas b/source/units/Goccia.FFI.ABI.Test.pas new file mode 100644 index 000000000..e1149ee45 --- /dev/null +++ b/source/units/Goccia.FFI.ABI.Test.pas @@ -0,0 +1,212 @@ +program Goccia.FFI.ABI.Test; + +{$I Goccia.inc} + +uses + TestingPascalLibrary, + + Goccia.FFI.ABI, + Goccia.FFI.Types; + +type + TFFIABITests = class(TTestSuite) + private + procedure ExpectPlacement(const APlan: TGocciaFFICompiledSignature; + const AArgumentIndex, APlacementIndex: Integer; + const AKind: TGocciaFFIPlacementKind; + const ARegisterIndex, AStackOffset: Integer); + procedure TestMixedScalarClassification; + procedure TestHighArityPlacement; + procedure TestWin64VariadicFloatMirroring; + procedure TestDarwinARM64VariadicTailPlacement; + public + procedure SetupTests; override; + end; + +procedure TFFIABITests.SetupTests; +begin + Test('mixed scalar arguments are classified independently', + TestMixedScalarClassification); + Test('high arity arguments spill through the ABI planner', + TestHighArityPlacement); + Test('Win64 variadic floats are mirrored into integer registers', + TestWin64VariadicFloatMirroring); + Test('Darwin ARM64 variadic tails use stack slots', + TestDarwinARM64VariadicTailPlacement); +end; + +procedure TFFIABITests.ExpectPlacement( + const APlan: TGocciaFFICompiledSignature; + const AArgumentIndex, APlacementIndex: Integer; + const AKind: TGocciaFFIPlacementKind; + const ARegisterIndex, AStackOffset: Integer); +var + Placement: TGocciaFFIPlacement; +begin + Placement := APlan.Arguments[AArgumentIndex].Placements[APlacementIndex]; + Expect(Ord(Placement.Kind)).ToBe(Ord(AKind)); + Expect(Placement.RegisterIndex).ToBe(ARegisterIndex); + Expect(Placement.StackOffset).ToBe(AStackOffset); +end; + +procedure TFFIABITests.TestMixedScalarClassification; +var + Arguments: array[0..3] of TGocciaFFITypeDescriptor; + ReturnType: TGocciaFFITypeDescriptor; + Plan: TGocciaFFICompiledSignature; + ABI: TGocciaFFIABI; + I: Integer; +begin + Arguments[0] := TGocciaFFITypeDescriptor.CreateScalar(fftI32); + Arguments[1] := TGocciaFFITypeDescriptor.CreateScalar(fftF32); + Arguments[2] := TGocciaFFITypeDescriptor.CreateScalar(fftI32); + Arguments[3] := TGocciaFFITypeDescriptor.CreateScalar(fftF32); + ReturnType := TGocciaFFITypeDescriptor.CreateScalar(fftVoid); + try + for ABI := Low(TGocciaFFIABI) to High(TGocciaFFIABI) do + begin + Plan := TGocciaFFICompiledSignature.Create(ABI, Arguments, ReturnType); + try + case ABI of + fabiSysVX64: + begin + ExpectPlacement(Plan, 0, 0, fpkGPR, 0, 0); + ExpectPlacement(Plan, 1, 0, fpkFPR, 0, 0); + ExpectPlacement(Plan, 2, 0, fpkGPR, 1, 0); + ExpectPlacement(Plan, 3, 0, fpkFPR, 1, 0); + end; + fabiWin64: + begin + ExpectPlacement(Plan, 0, 0, fpkGPR, 0, -1); + ExpectPlacement(Plan, 1, 0, fpkFPR, 1, -1); + ExpectPlacement(Plan, 2, 0, fpkGPR, 2, -1); + ExpectPlacement(Plan, 3, 0, fpkFPR, 3, -1); + end; + fabiAAPCS64, fabiDarwinARM64: + begin + ExpectPlacement(Plan, 0, 0, fpkGPR, 0, 0); + ExpectPlacement(Plan, 1, 0, fpkFPR, 0, 0); + ExpectPlacement(Plan, 2, 0, fpkGPR, 1, 0); + ExpectPlacement(Plan, 3, 0, fpkFPR, 1, 0); + end; + fabiI386Win: + begin + ExpectPlacement(Plan, 0, 0, fpkStack, -1, 0); + ExpectPlacement(Plan, 1, 0, fpkStack, -1, 4); + ExpectPlacement(Plan, 2, 0, fpkStack, -1, 8); + ExpectPlacement(Plan, 3, 0, fpkStack, -1, 12); + end; + end; + finally + Plan.Free; + end; + end; + finally + ReturnType.ReleaseReference; + for I := 0 to High(Arguments) do + Arguments[I].ReleaseReference; + end; +end; + +procedure TFFIABITests.TestHighArityPlacement; +var + Arguments: array[0..8] of TGocciaFFITypeDescriptor; + ReturnType: TGocciaFFITypeDescriptor; + Plan: TGocciaFFICompiledSignature; + ABI: TGocciaFFIABI; + I: Integer; +begin + for I := 0 to High(Arguments) do + Arguments[I] := TGocciaFFITypeDescriptor.CreateScalar(fftI32); + ReturnType := TGocciaFFITypeDescriptor.CreateScalar(fftVoid); + try + for ABI := Low(TGocciaFFIABI) to High(TGocciaFFIABI) do + begin + Plan := TGocciaFFICompiledSignature.Create(ABI, Arguments, ReturnType); + try + case ABI of + fabiSysVX64: + ExpectPlacement(Plan, 8, 0, fpkStack, -1, 16); + fabiWin64: + ExpectPlacement(Plan, 8, 0, fpkStack, -1, 32); + fabiAAPCS64, fabiDarwinARM64: + ExpectPlacement(Plan, 8, 0, fpkStack, -1, 0); + fabiI386Win: + ExpectPlacement(Plan, 8, 0, fpkStack, -1, 32); + end; + finally + Plan.Free; + end; + end; + finally + ReturnType.ReleaseReference; + for I := 0 to High(Arguments) do + Arguments[I].ReleaseReference; + end; +end; + +procedure TFFIABITests.TestWin64VariadicFloatMirroring; +var + Arguments: array[0..1] of TGocciaFFITypeDescriptor; + ReturnType: TGocciaFFITypeDescriptor; + Plan: TGocciaFFICompiledSignature; + I: Integer; +begin + Arguments[0] := TGocciaFFITypeDescriptor.CreateScalar(fftF64); + Arguments[1] := TGocciaFFITypeDescriptor.CreateScalar(fftF64); + ReturnType := TGocciaFFITypeDescriptor.CreateScalar(fftVoid); + try + Plan := TGocciaFFICompiledSignature.Create(fabiWin64, Arguments, + ReturnType, 1); + try + Expect(Length(Plan.Arguments[0].Placements)).ToBe(2); + ExpectPlacement(Plan, 0, 0, fpkFPR, 0, -1); + ExpectPlacement(Plan, 0, 1, fpkGPR, 0, -1); + Expect(Length(Plan.Arguments[1].Placements)).ToBe(2); + ExpectPlacement(Plan, 1, 0, fpkFPR, 1, -1); + ExpectPlacement(Plan, 1, 1, fpkGPR, 1, -1); + finally + Plan.Free; + end; + finally + ReturnType.ReleaseReference; + for I := 0 to High(Arguments) do + Arguments[I].ReleaseReference; + end; +end; + +procedure TFFIABITests.TestDarwinARM64VariadicTailPlacement; +var + Arguments: array[0..2] of TGocciaFFITypeDescriptor; + ReturnType: TGocciaFFITypeDescriptor; + Plan: TGocciaFFICompiledSignature; + I: Integer; +begin + Arguments[0] := TGocciaFFITypeDescriptor.CreateScalar(fftF64); + Arguments[1] := TGocciaFFITypeDescriptor.CreateScalar(fftF64); + Arguments[2] := TGocciaFFITypeDescriptor.CreateScalar(fftI64); + ReturnType := TGocciaFFITypeDescriptor.CreateScalar(fftVoid); + try + Plan := TGocciaFFICompiledSignature.Create(fabiDarwinARM64, Arguments, + ReturnType, 1); + try + ExpectPlacement(Plan, 0, 0, fpkFPR, 0, 0); + ExpectPlacement(Plan, 1, 0, fpkStack, -1, 0); + ExpectPlacement(Plan, 2, 0, fpkStack, -1, 8); + Expect(Plan.StackSize).ToBe(16); + finally + Plan.Free; + end; + finally + ReturnType.ReleaseReference; + for I := 0 to High(Arguments) do + Arguments[I].ReleaseReference; + end; +end; + +begin + TestRunnerProgram.AddSuite(TFFIABITests.Create('Goccia.FFI.ABI')); + TestRunnerProgram.Run; + + ExitCode := TestResultToExitCode; +end. diff --git a/tests/built-ins/FFI/ffi-v2-types.js b/tests/built-ins/FFI/ffi-v2-types.js index c86401d25..13a1e91f3 100644 --- a/tests/built-ins/FFI/ffi-v2-types.js +++ b/tests/built-ins/FFI/ffi-v2-types.js @@ -19,6 +19,13 @@ describe("FFI type descriptors", () => { returns: "i32", }); const metadata = FFI.metadata(collision); + const Holder = FFI.struct({ prefix: "i32", collision: Collision }); + const holder = Holder.create({ + prefix: 9, + collision: Collision.create({ buffer: 11, byteOffset: 13 }), + }); + const nestedCollision = holder.collision; + const nestedMetadata = FFI.metadata(nestedCollision); const Bytes = FFI.array("u8", 4); const bytes = Bytes.create([1, 2, 3, 4]); const bytesMetadata = FFI.metadata(bytes); @@ -30,6 +37,11 @@ describe("FFI type descriptors", () => { expect(metadata.buffer).toBeInstanceOf(ArrayBuffer); expect(metadata.byteOffset).toBe(0); expect(metadata.size).toBe(8); + expect(nestedCollision.buffer).toBe(11); + expect(nestedCollision.byteOffset).toBe(13); + expect(nestedMetadata.buffer).toBe(holder.buffer); + expect(nestedMetadata.byteOffset).toBe(4); + expect(nestedMetadata.size).toBe(8); expect(bytesMetadata.buffer).toBe(bytes.buffer); expect(bytesMetadata.byteOffset).toBe(0); expect(bytesMetadata.size).toBe(4); diff --git a/tests/built-ins/FFI/prototype/bind.js b/tests/built-ins/FFI/prototype/bind.js index d2cfab81b..71813eb6b 100644 --- a/tests/built-ins/FFI/prototype/bind.js +++ b/tests/built-ins/FFI/prototype/bind.js @@ -70,6 +70,7 @@ describe("FFILibrary.prototype.bind", () => { expect(strlen("h\u00e9\u{1F600}")).toBe(7); expect(strlen(null)).toBe(-1); + expect(() => strlen(123)).toThrow(TypeError); expect(() => strlen("\uD800")).toThrow(TypeError); expect(() => strlen("before\0after")).toThrow(TypeError); }); diff --git a/tests/built-ins/FFI/prototype/variadic.js b/tests/built-ins/FFI/prototype/variadic.js index 054472069..6e7a85c9c 100644 --- a/tests/built-ins/FFI/prototype/variadic.js +++ b/tests/built-ins/FFI/prototype/variadic.js @@ -18,6 +18,7 @@ describe("FFILibrary.prototype.bind (variadic signatures)", () => { ), ), ).toBe(3); + expect(sum(0, FFI.varargs([], []))).toBe(0); }); test("applies C default argument promotions to the variadic tail", () => { From 1c919ee4eb3f8d69f0b613bad1f2bf608a9ea1a7 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Fri, 24 Jul 2026 13:48:59 +0100 Subject: [PATCH 3/3] fix(ffi): clarify lifetime and argument bounds --- docs/adr/0102-explicit-bounded-ffi-call-descriptors.md | 10 ++++++---- docs/built-ins-ffi.md | 2 +- tests/built-ins/FFI/prototype/variadic.js | 5 ++++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/adr/0102-explicit-bounded-ffi-call-descriptors.md b/docs/adr/0102-explicit-bounded-ffi-call-descriptors.md index 9fec891ee..5ec897508 100644 --- a/docs/adr/0102-explicit-bounded-ffi-call-descriptors.md +++ b/docs/adr/0102-explicit-bounded-ffi-call-descriptors.md @@ -23,10 +23,12 @@ 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`. It 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. +`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 diff --git a/docs/built-ins-ffi.md b/docs/built-ins-ffi.md index f314ac70d..caf7ff313 100644 --- a/docs/built-ins-ffi.md +++ b/docs/built-ins-ffi.md @@ -92,7 +92,7 @@ stringLength("hello"); // 5 stringLength(null); // native NULL ``` -Non-null values must be strings containing well-formed UTF-16 without embedded NUL characters. 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. +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 diff --git a/tests/built-ins/FFI/prototype/variadic.js b/tests/built-ins/FFI/prototype/variadic.js index 6e7a85c9c..c75b29150 100644 --- a/tests/built-ins/FFI/prototype/variadic.js +++ b/tests/built-ins/FFI/prototype/variadic.js @@ -101,11 +101,14 @@ describe("FFILibrary.prototype.bind (variadic signatures)", () => { const types = []; const values = []; - for (const unused of new Array(64)) { + for (const unused of new Array(63)) { types.push("i32"); values.push(1); } + expect(sum(63, FFI.varargs(types, values))).toBe(63); + types.push("i32"); + values.push(1); expect(() => sum(64, FFI.varargs(types, values))).toThrow(RangeError); }); });