Fels talks to C libraries directly. You declare the functions you need with #extern, link the
import library, and call them. Structs cross the boundary by value with the correct platform
ABI. And because nobody wants to write hundreds of declarations by hand, an automated generator
turns C headers into Fels bindings for you.
#extern("<lib>", "<symbol>") binds an external C function. You leave out the body, because it
lives in the C library, and the <lib> is pulled into the link automatically.
#foreign(...) is an alias for #extern(...). For a library you depend on but don’t declare any
functions from directly, use #system_library("name");. It is a standalone linking directive and
introduces no symbols.
std.c provides C-ABI type aliases (c_int, c_long, c_size_t, c_string, …). They are
sized for 64-bit Windows (LLP64), and they let your signatures read like their C originals:
#import std.c.{ c_int, c_string };
#extern("kernel32", "lstrlenA")
lstrlenA::fn(s: c_string) -> c_int; // int lstrlenA(const char *s)
They are transparent aliases. A c_intis an i32, so values flow
between the two without a cast, and core::sizeof(c_int) is 4.
alias
Fels
C
c_char, c_schar, c_uchar
i8, i8, u8
char, signed char, unsigned char
c_short, c_ushort
i16, u16
short, unsigned short
c_int, c_uint
i32, u32
int, unsigned int
c_long, c_ulong
i32, u32
long, unsigned long (LLP64)
c_longlong, c_ulonglong
i64, u64
long long, unsigned long long
c_size_t, c_ssize_t
usize, isize
size_t, ssize_t/ptrdiff_t
c_intptr, c_uintptr, c_voidptr
isize, usize, usize
intptr_t, uintptr_t, void*
c_float, c_double
f32, f64
float, double
c_bool
bool
_Bool
c_string
[*]const u8
const char* (NUL-terminated)
These sizes don’t change on Linux x86-64, where C long is 64 bits, so use c_longlong and
c_ulonglong for long and unsigned long there.
c_char is the signed i8, so a pointer to it does not accept a Fels string ([*]const u8).
For const char* parameters, use c_string instead.
A string literal ends in a NUL that its length leaves out. That makes "title".ptr a C string,
and you can pass it as it is. Other text has no NUL after it: a slice of a literal, text read
from a file, or text built at run time. Copy such text into a buffer and add the NUL before you
give it to C.
You choose the Fels name of a binding. It may even be the name of a function that the runtime or
another module already declares, such as CloseHandle, GetStdHandle or WriteFile, and your
declaration then shadows it inside your own file. Several bindings can also share one C symbol
with different signatures. Each call is compiled with the signature of the binding it names, so
a BOOL can be i32 in one module and bool in another.
#extern("kernel32", "CloseHandle")
CloseHandle::fn(handle: usize) ->i32; // the C header's BOOL
A C function that takes ... is declared with ... after its parameters:
#extern("c", "printf")
printf::fn(fmt: [*]constu8, ...) ->i32;
main::fn() ->i32 {
count: u8=3;
printf("%s: %d at %.2f\n".ptr, "apples".ptr, count, 0.5);
return0;
}
A call can pass any number of arguments after the parameters. Each one goes the way a C caller
passes it:
argument
C receives it as
i8, i16, u8, u16, i1–i7, u1–u7, bool
an int
an integer literal
an int, or a long long if it does not fit
i32, u32, i64, u64, isize, usize
itself
f32, f64, a float literal
a double
a pointer (*T, [*]T, ?*T)
itself
Any other argument is an error: a slice, a struct, or a Fels function. Pass a C string as its
.ptr. The compiler doesn’t read the format string, so its % codes have to match these
types: %d for an int, %lld for an i64, %zu for a usize, %f for a double.
Only an #extern function can take .... A Fels function takes any number of values as a
slice. You can only call a variadic function. Taking one as a value is an error, because a
Fels function value has a fixed number of parameters.
C UI and graphics libraries pass small aggregates by value everywhere. Declare the struct on the
Fels side so it matches the C layout, and pass it directly. The compiler applies the Win64 C ABI
at the boundary:
This matches what a C compiler (clang or MSVC) does, so the bits arrive intact. Win64 does not
treat floating-point struct members specially. Only naked float/double scalar arguments
use XMM registers, so Vector2 correctly travels as a 64-bit integer. For a runnable round-trip,
see examples/c_interop/.
Struct fields keep their declaration order at their natural alignment. A struct that mirrors a
C struct therefore has C’s size and field offsets. #packed removes the padding, like
#pragma pack(1). #align(N) works like alignas(N): it raises the struct’s alignment and
rounds its size up. That also holds when the struct is a field of another struct or an array
element.
core::sizeof, core::align_of and core::offset_of report the layout at compile time. With
#assert you can write down an assumption about it, and the build fails when the assumption is
wrong:
The numbers in that example are those of windows.h, checked with clang against the Windows SDK
headers. Beyond this one struct, the compiler’s answers were compared with clang’s layout for
x86_64-pc-windows-msvc. The test structs mixed u8–u64, f64, bool, pointers, arrays,
nesting, #packed and #align. Slice and fn fields were compared with the layout LLVM emits
for them.
Here is what is guaranteed on the supported targets (Windows and Linux, x86-64):
The largest field alignment, and the size is a multiple of it.
#packed
No padding, alignment 1. It takes no argument, so there is no equivalent of #pragma pack(2) or pack(4) (#packed(2) is an error). Sub-byte fields (u1–u7) are bit-packed from the lowest bit up. That is not C’s bitfield layout, which groups bitfields into units of their declared type, so you match a C bitfield struct with explicit filler fields.
#align(N)
Alignment at least N, size rounded up to a multiple of N.
[]T, string
16 bytes: pointer then length.
extern fn(...)
A thin 8-byte code pointer (a C function pointer), in a field as anywhere else. A plain fn(...) is a 16-byte {fn, env} closure, in a field too.
?*T
An 8-byte nullable pointer.
union
Every field at offset 0. The size is that of the largest field, rounded up to the strictest field alignment.
And here is what is not guaranteed, and so not offered:
The layout of an enum, an error set or an interface value. A payload enum happens to be a
4-byte tag followed by a byte array, but that is an implementation detail. core::align_of
and core::offset_of reject these types, and a struct that contains one has no offset_of
for the fields after it.
Opaque types (Name :: opaque;) and other incomplete types, which have no size.
Anything on a 32-bit target. The table assumes 8-byte pointers and 8-byte alignment for
64-bit integers, which is what Fels’s current targets have.
core::offset_of takes the field name as a string literal and reports the offset of a top-level
field. For a nested field, add up the offsets: offset_of(Outer, "inner") + offset_of(Inner, "x").
A C union is a Fels union (see 02_types). Every field sits at offset 0, and the
size is that of the largest field, rounded up to the strictest alignment. That is C’s rule too.
Reading a field is unsafe, because it reinterprets the bytes, but writing one is not.
// typedef union { struct { DWORD LowPart; LONG HighPart; }; LONGLONG QuadPart; } LARGE_INTEGER;
LowHigh::struct { low: u32, high: i32 }
LargeInteger::union { quad: i64, parts: LowHigh }
li: LargeInteger= .{ -2i64 };
low :=unsafe (li.parts.low); // 4294967294; li.parts.high is -1
#export makes a function visible to a C host, and it does the same for a global. A C host finds
a plugin’s entry point by name, and what it finds is data. CLAP’s, for example, is
extern const clap_plugin_entry_t clap_entry;, a struct of version numbers and function
pointers. A global’s initializer is laid out in the image at build time (see “Static
aggregates” in 01_syntax), so the same declaration works here:
calls: i32=0; // a plain exported global; the host reads it, the library writes it
#export("name") exports the global under name, and a bare #export uses the global’s own
name. An immutable constant (#export("limit") LIMIT :: 42;) is exported too, in read-only
memory. The extern fn(...) fields of PluginEntry are bare code addresses, so a C declaration
with function pointers of the same signatures has the same layout. You can check that with
#assert(core::sizeof(...)) and core::offset_of (see “Checking a layout”).
On Windows the symbol goes into the DLL’s export table, where GetProcAddress (or Python’s
ctypes.c_int32.in_dll) finds it. The address is the global’s own storage, so the host and the
library see each other’s writes. examples/dll is a working
plugin-shaped library, loaded from Python.
A DLL that holds addresses in its data (like the function pointers above) carries a
base-relocation table. It works wherever the loader maps it, and two of them can be loaded into
one process. A C program can also link against the import library that LLD writes beside the
DLL, and declare the data __declspec(dllimport) extern. An int, an array, a struct of
function pointers and a string all read and write correctly that way, and the library sees the
program’s writes. (This was tested with a freestanding C program compiled by clang.) A DLL that
felsld links has no import library, and felsld links only DLLs that hold no address. For those
DLLs, and for any host that prefers it, resolve the export by name.
On Linux the target is a static executable, so there is no shared object to export from. There,
#export gives the global a global symbol under its exported name, which a debugger or nm can
see.
A COM interface is a pointer to an object whose first word points to a table of function
pointers, and every method takes the interface pointer first. In Fels that is exactly a struct
with a *Vtbl field and a vtable struct of extern fn(...) fields, called as
p.vtbl.method(p, ...).
The standard library supplies the shared pieces.
std.com has Guid, HRESULT,
IUnknown and IInspectable, and
std.winrt covers
starting COM and the Runtime, creating objects and HSTRING. You declare the interfaces you call
yourself. List the vtable in header order and #assert its core::offset_of values against the
header. To get those values, compile the header with clang and print offsetof. An object you
implement is a struct with the vtable pointer first and a static vtable (see “Static aggregates”
in 01_syntax), and the operating system can call back into it.
Linking a non-system import library currently goes through LLD, because the self-hosted felsld
doesn’t read .lib archives yet. fels configures lld-link for you.
Fels links with /NODEFAULTLIB and its own tiny runtime, and it does not pull in the C
runtime. That’s fine for pure Fels, but a C library brings its code and not the CRT it depends
on. A static library (for example a raylib.lib built against the CRT) calls
malloc/free/calloc/realloc and the compiler’s stack and security helpers (__chkstk,
__report_rangecheckfailure). Linking it without the CRT therefore fails with
undefined symbol: malloc and friends.
To fix that, link the C runtime:
CLI:fels build app.fels --libc (or -lc)
build.fels:app.link_libc();
link_libc pulls in the UCRT (ucrt.lib, for malloc/free/…) and the VC runtime
(vcruntime.lib, for __chkstk and the security checks). Both search paths are found for you.
The UCRT path is derived from the configured Windows SDK path. The MSVC lib/x64 directory,
where vcruntime.lib lives, is auto-discovered: fels scans the standard
…/Microsoft Visual Studio/<year>/<edition>/VC/Tools/MSVC/<ver>/lib/x64 locations and uses the
newest toolset it finds. So you normally don’t configure anything. If your toolchain is on a
non-default drive, override the discovery by building fels with
-Dmsvc-lib-path=<…/VC/Tools/MSVC/<ver>/lib/x64>, or pass --lib-path <that dir> on the
command line.
Fels’s own functions are module-private: they have internal linkage, and the whole program
is one object. So they never clash with the CRT’s exit/abort/etc. when you link libc. One
command then covers the C runtime of a static-raylib build. Its system libraries come on top,
as the note below explains:
(Or call app.link_libc() in a build.fels, and the MSVC path is discovered automatically.)
A static library also needs its own system dependencies. Static raylib, for example, also
wants gdi32, user32, winmm, shell32, and opengl32, and you add them with
app.link("gdi32") and so on. The DLL distribution of a library avoids all of this,
because the .dll already contains its CRT and dependencies. Prefer it when it is available.
You don’t rename or swap library files to switch the link mode, and in the common case you don’t
even say which mode you want. The build peeks inside each .lib you link and reacts to what
it finds. It looks in the artifact’s own lib_paths and in the directory containing
build.fels, so a raylib.lib sitting next to your build script is detected even with no
lib_path call. There are two cases:
an import library (it carries an __IMPORT_DESCRIPTOR_<dll> symbol) → the
build copies the matching .dll next to the output exe automatically, and
a static archive → the build links the C runtime automatically.
So this is usually all you write:
#import std.build.{ Build };
build::fn(b: Build) {
game := b.executable("game", "src/main.fels");
game.link("raylib");
game.lib_path("vendor/raylib/lib"); // whichever raylib.lib is here decides the mode
b.default(game);
}
If vendor/raylib/lib/raylib.lib is the import library, you’ll see
raylib → dynamic (raylib.dll) and the DLL lands next to the exe. If it’s the static archive,
the C runtime is linked for you. The bindings (#extern("raylib", …)) never change. Only the
raylib.lib on the path does.
A static C library also needs its own system deps (static raylib wants
opengl32/gdi32/…). You normally don’t list them. When a static C library is linked, Fels
honors that library’s own embedded /DEFAULTLIB directives, exactly like a C compiler does, so
its system dependencies flow in automatically:
b.default(game); // opengl32/gdi32/winmm pulled in for you
}
Fels keeps a minimal runtime by default. It links /NODEFAULTLIB and provides its own tiny
runtime, so a plain Fels program pulls in nothing. The honoring kicks in only when a static C
library is involved. Even then, Fels suppresses only the CRT-startup umbrella libs
(libcmt/msvcrt/…), because their mainCRTStartup would clash with Fels’s entry. The real
system deps (opengl32, gdi32, …) still get through. The C runtime itself (malloc,
__chkstk, …) comes from ucrt+vcruntime, which the static auto-detection already links.
Caveat: libraries that don’t self-declare their deps. Honoring only pulls in what the
.librecords. A library that uses Win32 APIs without #pragma comment(lib, …) won’t list
them, so you still link those yourself. The prebuilt static raylib is the classic case. It
embeds winmm but not the GUI/GL libraries glfw uses, so a complete static-raylib build is:
app.link("shell32"); app.link("winmm"); // raylib's GUI deps it doesn't declare
b.default(app);
Run it from the folder that holds raylib.lib (or add app.lib_path("…")). The DLL
distribution avoids the whole list, because its .dll already carries the CRT and these
dependencies. Prefer it when you can.
Opting out: game.no_default_libs(). If you’d rather keep the strict, minimal linking and
control every dependency yourself, call no_default_libs(). The library’s /DEFAULTLIB
directives are then ignored, and you list the system deps explicitly:
You can’t make an import library link statically, because it has no code in it, only a pointer
to the DLL. So “forcing static” means making the linker pick the static archive, and you
control that by what’s on the search path:
If the static and import raylib.lib live in different folders, add only the static
folder to lib_path, or list it first, because the first raylib.lib found wins. The build
sees a static archive → static link.
If the two have different names (raylib.lib static, raylibdll.lib import), choose
with link("raylib") or link("raylibdll"). The name picks the file.
To make the intent safe, call game.static_link(). If the raylib.lib actually resolved on
your path turns out to be an import library, you get a clear warning
(requested static linking, but '…/raylib.lib' is an import library — the link will be DYNAMIC) instead of silently shipping a dynamic build.
That API is deliberately platform-agnostic, so the same build.fels calls work for Linux
today and are meant to carry over to macOS. This is how each call maps:
build.fels
Windows
Linux
macOS (planned)
link("raylib")
raylib.lib
-lraylib → libraylib.so/.a
-lraylib → libraylib.dylib/.a
link_mode(.dynamic)
import lib + ship .dll
.so only in a glibc build, no rpath
.dylib + rpath
link_mode(.static)
archive + CRT
links glibc, as link_libc() does
.a + libSystem
runtime_file(x)
copy next to exe
copy next to exe
(rpath)
link_libc()
ucrt + vcruntime
glibc (libc.so.6)
libSystem
Fels emits and links Windows and Linux on x86-64. Windows gets PE through felsld or
lld-link with the Win64 ABI. Linux gets ELF through ld.lld with the SysV ABI, either static
without libc (--target linux, a.target(.linux)) or against glibc (--target linux-gnu,
a.target(.linux_gnu)). For the details, see the Linux backend and
targets and tiers. macOS is not there yet. It needs Mach-O linking
(ld64.lld), the AArch64 aggregate ABI, and its own runtime and entry. None of that requires
changing a build.fels, because the link-mode/runtime_file/named-library design above absorbs
it.
fels bindgen parses an arbitrary C header with libclang and emits a Fels module:
Terminal window
felsbindgenraylib.h--libraylib-oraylib.fels
It maps these constructs:
C functions → #extern("<lib>", "<name>") pub name :: fn(...) -> ...;
C structs → pub Name :: struct { ... }
C enums → pub CONST :: <value>; (plus a pub Name :: i32 alias)
C typedefs → pub Name :: <type>;
and a leading #system_library("<lib>")
Then you use it like any other module:
#import raylib.*;
main::fn() ->i32 {
InitWindow(800, 450, "fels + raylib".ptr);
// ...
return0;
}
A few options control it. --lib <name> sets the import-library name (the default is the header
stem), and -o <file> sets the output path. -I<dir> / -D<sym> are forwarded to clang, and
anything after -- is passed to clang verbatim, for example extra -isystem paths for libraries
that include system headers. fels bindgen requires an LLVM-enabled build (libclang ships in the
same SDK).
It also maps object-like #define constants. A plain numeric or string macro becomes
pub NAME :: <value>;. A compound-literal macro, such as raylib’s
#define RAYWHITE CLITERAL(Color){ 245,245,245,255 }, becomes a comptime-folded typed constant,
so colors and flags work with no extra effort:
bindgen handles the awkward parts of real headers too:
Function pointers / callbacks become a real Fels function type. It is expanded inline at
the use site, because Fels has no Name :: fn(...) type-alias form:
void set_logger(LogCallback) → pub set_logger :: fn(cb: extern fn(i32, [*]const u8) -> void);.
You can pass a Fels function as the callback, and C will call back into it.
Unions become a Fels union with the same fields.
Bitfield structs (and unions with a bitfield) become a size- and alignment-correct
opaque blob (#align(A) struct { _bytes: [N]u8 }). That way by-value passing stays
ABI-correct, and you read the contents via casts.
Opaque / forward-declared types (typedef struct Foo Foo; handles) become
pub Foo :: opaque;, so *Foo resolves and the module always compiles.
Variadic functions keep their ... (see “Variadic functions” above):
int log_msg(int level, const char *fmt, ...) → pub log_msg :: fn(level: i32, fmt: [*]const u8, ...) -> i32;.
long double maps to f64 (they’re identical on Win64).
Still not handled: function-like / expression #define macros (such as
#define DEG2RAD (PI/180.0)). If a header is unusually complex, skim the generated bindings
before you rely on them.