Status: proposal, not implemented. Captures what it would take to let
Silicon pass and return C structs by value across @extern, so that
struct-based native APIs (raylib's Camera3D / Color / Vector3, most of
libc's stat/timespec, …) become callable.
Motivation: examples/cube.si draws a rotating cube
with raylib but has to route through raylib's primitive-only rlgl
immediate-mode API and do all 3-D maths in Silicon, because the high-level
raylib API passes Vector3 / Color / Camera3D by value and Silicon's FFI
can only pass scalars. This doc is the plan to remove that limitation.
There are two backends with fundamentally different struct stories:
- WASM. Core WebAssembly has no struct-by-value calling convention; params
are scalar
i32/i64/f32/f64. C libraries compiled to wasm (Emscripten) use a bespoke ABI that marshals structs through linear memory, and WasmGC structs are GC heap references, not C value-structs. So "pass a struct to a C function by value" has no meaning on the wasm target — at most you pass a pointer into linear memory by convention (seeextern-out-pointer.md), which the native raylib API does not accept. - Native (QBE). This is where struct-by-value is both meaningful and achievable. Everything below targets the QBE backend.
The wasm target should therefore reject a by-value struct in an @extern
signature with a clear diagnostic, and keep offering the out-pointer convention.
The hard part of struct-by-value is the System V AMD64 ABI eightbyte classification: structs ≤ 16 bytes are split field-by-field across INTEGER and SSE registers; larger structs go on the stack (MEMORY class); some returns come back through a hidden pointer. For raylib:
| C type | size | SysV passing |
|---|---|---|
Color (4×u8) |
4 B | one INTEGER register (packed) |
Vector2 (2×float) |
8 B | one SSE (XMM) register |
Vector3 (3×float) |
12 B | two SSE registers |
Rectangle / Vector4 (4×float) |
16 B | two SSE registers |
Camera3D (3×Vector3 + float + int) |
44 B | MEMORY — on the stack |
Matrix (16×float) |
64 B | MEMORY — on the stack |
QBE implements all of this. It has first-class aggregate types and lowers by-value aggregate arguments/returns per the target ABI (amd64 sysv, arm64, rv64) for us:
type :Color = { b 4 } # 4 contiguous bytes
type :Vector3 = { s 3 } # 3 contiguous f32
type :Camera3D = { :Vector3, :Vector3, :Vector3, s, w } # 44 B, nested
# Color c = {230,41,55,255}; DrawCubeV(pos, size, c);
call $DrawCubeV(:Vector3 %pos, :Vector3 %size, :Color %c)
# Vector2 m = GetMousePosition();
%m =:Vector2 call $GetMousePosition() # QBE returns the aggregate
So Silicon does not need to implement the eightbyte classifier. It needs to
(a) emit the right type :T declarations and (b) hand QBE aggregate values at
the call boundary. The current QBE backend emits zero aggregate declarations
(src/codegen/qbe/lower.ts:267 — @struct /
@type produce no top-level QBE output; field access is generated functions
over a flat blob).
This is a language problem, not just codegen. Today a Silicon struct (see
struct-design.md) is:
- a heap pointer (
i32), not a value — the opposite of by-value; - fields at sequential byte offsets in declaration order;
- every
Int/Bool/Floatfield is 4 bytes (no sub-word fields),Int64is 8 bytes; - no alignment or padding rules;
- no nested structs ("field lowering assumes all fields are scalar wasm
types",
struct-design.md§Limitations).
Mismatches with the C value-struct ABI:
- Pointer, not value. Need the bytes in registers/stack at the boundary.
- No sub-word fields.
Coloris 4×u8= 4 bytes; four 4-byte slots give 16 bytes — wrong size and wrong register class. - No padding/alignment. C uses natural field alignment + struct tail
padding; sequential offsets mismatch as soon as widths differ (e.g. an
i64after ani32). - No nested structs.
Camera3Dembeds threeVector3s. Float=f32already matches Cfloat(good — raylib's vectors are allfloat).doublefields would need anf64/Doubletype Silicon lacks (tracked as a separate gap, §7).
Introduce a distinct definition keyword — @cstruct — rather than
overloading @struct (keeps the wasm-friendly @struct untouched, and a new
keyword is a stratum, so no grammar change, per the project's
"add a stratum, not grammar" rule):
@cstruct Color := { r UInt8, g UInt8, b UInt8, a UInt8 }; # 4 B
@cstruct Vector2 := { x Float, y Float }; # 8 B
@cstruct Vector3 := { x Float, y Float, z Float }; # 12 B
@cstruct Camera3D := { position Vector3, target Vector3, up Vector3, fovy Float, projection Int };
@cstruct semantics:
- Field types restricted to C-representable types:
UInt8/16/32/64,Int/Int32/Int64,Float(f32), pointers (String/Array), and nested@cstructtypes. - Layout computed with C rules: each field at the next offset that is a multiple of its alignment; struct alignment = max field alignment; size rounded up to a multiple of struct alignment (tail padding).
- A value is still represented internally as a pointer to a contiguous C-layout block (minimal disruption: construction, field read/write reuse the existing pointer machinery). By-value only materializes at the FFI boundary.
All on the QBE path; siliconTypeToQbe/siliconTypeToQbeReturn and lowerCall
in src/codegen/qbe/ are the touch-points.
- Type emission. For each
@cstructreachable from an@externsignature, emit a QBEtype :T = { … }(nested types reference other:T). New pass inlowerTopLevel. - Argument passing.
lowerCallcurrently types each arg by itsinferredType → siliconTypeToQbe(lower.ts:816). When the callee parameter is a@cstruct, pass:T %ptr(the value is the pointer to the C-layout block) and let QBE classify. - Returns. Support
function :T $f(...)and%r =:T call $f(...); allocate a result slot, let QBE write the returned aggregate, hand Silicon back a pointer to it.siliconTypeToQbeReturnonly knows scalars today. - Extern signatures naming
@cstructtypes. The parser/typechecker already accept type-name params; they need to resolve to@cstructtypes and carry the layout to the lowerer. - Construction & field access. Struct literals (
Color(230, 41, 55, 255)) and.fieldreads/writes already exist for@struct; reuse with the C layout offsets and sub-word loads/stores (loadub/storebforUInt8, etc.).
The typechecker already has nominal struct types and field resolution
(ctx.structFields, preRegisterStructType); the missing piece is byte-exact
layout metadata threaded to the QBE lowerer, plus the four wiring points above.
@cstruct Color := { r UInt8, g UInt8, b UInt8, a UInt8 };
@cstruct Vector3 := { x Float, y Float, z Float };
@cstruct Camera3D := { position Vector3, target Vector3, up Vector3, fovy Float, projection Int };
@extern InitWindow width Int, height Int, title String;
@extern BeginMode3D camera Camera3D; # 44 B struct, by value
@extern DrawCube position Vector3, w Float, h Float, l Float, color Color;
@extern EndMode3D;
\\ main Int
@fn main := {
InitWindow(800, 600, '3D cube');
cam := Camera3D(
Vector3(10.0, 10.0, 10.0), Vector3(0.0, 0.0, 0.0),
Vector3(0.0, 1.0, 0.0), 45.0, 0);
BeginMode3D(cam);
DrawCube(Vector3(0.0, 0.0, 0.0), 2.0, 2.0, 2.0, Color(230, 41, 55, 255));
EndMode3D();
0
};
- MVP — register-class structs (≤ 16 B):
Color,Vector2,Vector3,Rectangle. Needs §3 layout + §4.1/§4.2/§4.5. This alone unlocks most of raylib's 2-D and a lot of 3-D drawing. - Struct returns (§4.3):
GetMousePosition,GetColor,ColorFromHSV. - MEMORY-class structs (> 16 B):
Camera3D,Matrix. Mostly "build in memory, pass:T %ptr" — QBE handles stack placement; main extra work is nested-struct layout and construction. - wasm-target rejection diagnostic for by-value struct externs.
Effort: the MVP is moderate — a few hundred lines plus tests — precisely because QBE absorbs the ABI. The bulk is the layout model and construct/marshal plumbing, not register allocation.
f64/Double. Independent of structs but adjacent: needed fordoublefields anddoubleparams/returns (GetTime,rlFrustum,rlOrtho). QBE has thedbase type; Silicon'sFloatisf32only. ADoubletype would slot intosiliconTypeToQbeasd.- Variadics (
printf) are a separate FFI item (QBE supports...in calls); unrelated to structs but often wanted alongside. - Available today without compiler changes:
- C shim — a few lines of C wrapping struct-taking functions behind
primitive signatures (
void DrawCubeXYZ(float,float,float, …, int,int,int)), compiled alongside the QBE assembly. rlglroute — raylib's primitive-only immediate-mode API, as used inexamples/cube.si.
- C shim — a few lines of C wrapping struct-taking functions behind
primitive signatures (
- Should
@cstructand@structunify (one keyword, C layout inferred when all fields are C-representable), or stay distinct for clarity? Distinct is the safer first cut. - Native heap for
@cstructblocks: reuse the wasm-style bumpalloc, or move to realmalloc/arena on native? The cube example shows the native backend already maps the linear-memory model to QBE memory ops. - Passing a
@cstructby pointer to an extern that wantsT*(vs by value) — likely just "take the address", but the surface for "address-of" needs a decision.
struct-design.md— current@structlayout (the starting point this proposal extends).extern-out-pointer.md— the out-pointer convention (the wasm-side answer to "return more than a scalar").targets.md— WASM vs native targets,@externpatterns, native string layout.examples/cube.si— the motivating example.- QBE language spec, "Aggregate Types" and "ABI" sections — the mechanism this design leans on.