Skip to content

15. C++ interoperability

This is a headline feature, not an afterthought. Because Dazzle emits C++, calling C++ is a direct call in the generated source — no wrappers, no shims, no ABI marshalling, no overhead. The importer’s only job is to teach the type checker what the header contains.

A C or C++ library is vendored as source and built by dz — no Makefiles, no binaries to find for each platform. Its recipe says which files to compile and how, or names the library’s own CMake build (below), and lives in the dazzle.toml of the module that uses it:

[cpp.libs.raylib]
root = "vendor/raylib" # the paths below are relative to this
sources = ["src/rcore.c", "src/rshapes.c", "src/rtextures.c", "src/rtext.c",
"src/rmodels.c", "src/raudio.c", "src/utils.c"]
include_dirs = ["src"]
defines = ["GRAPHICS_API_OPENGL_33"]
import = ["raylib.h"] # what Dazzle code sees: see below
[cpp.libs.raylib.target.windows] # added to the above when building for Windows
sources = ["src/rglfw.c"]
defines = ["PLATFORM_DESKTOP"]
link_libs = ["opengl32", "gdi32", "winmm"]
[cpp.libs.raylib.target.macos] # a macOS system library is a framework
frameworks = ["Cocoa", "IOKit", "CoreVideo", "OpenGL"]
[cpp.libs.rgfw] # a single-header library: no source to write
root = "vendor/rgfw"
header = "RGFW.h"
include_dirs = ["."]
defines = ["RGFW_IMPLEMENTATION"]
[cpp.libs.jolt] # a library that ships binaries instead
include_dirs = ["vendor/jolt/include"]
lib_dirs = ["vendor/jolt/lib"]
link_libs = ["Jolt"]
import = ["Jolt/Physics/Body/Body.h"]
[cpp] # for everything, not tied to one library
defines = ["JPH_PROFILE_ENABLED=0"]
  • sources are paths or globs. A glob skips what is not .c, .cc, .cpp or .cxx; a path written out must be one. .c files are compiled as C. Each source becomes one object under build/obj/, rebuilt only when the source, a header it includes, or its flags change — judged by content, as generated C++ is (§16.6).

  • A library is compiled with its own include_dirs and defines, and with its module’s [cpp] ones, at the build’s optimisation level, with warnings off: its warnings are not the project’s to fix.

  • Every path is relative to the manifest, or to the library’s root when it has one.

  • [cpp.target.<os>] and [cpp.libs.<name>.target.<os>] add to their base table when building for that target — windows, linux, macos or web. Every section is checked on every platform, so a recipe that is wrong for another platform says so here. A system library belongs in one of these and not in the base table: opengl32 exists only on Windows, and naming it at the top makes a recipe that cannot link anywhere else.

  • link_libs are passed as -l<name>. frameworks is the macOS counterpart, passed as -framework <name>, because a macOS system library is a framework and -l does not find one. It belongs in a target.macos section, and is ignored elsewhere.

  • flags are passed to the compiler for this library’s sources, after the ones dz chooses — so a recipe can override one, which is the point. It is the escape hatch for what a define cannot express: raylib’s rglfw.c includes GLFW’s Cocoa backend, so on macOS that translation unit is Objective-C despite its .c name and needs flags = ["-x", "objective-c"]. A library’s sources only: Dazzle’s own output must compile under the fixed set above, and a manifest does not get to weaken that.

  • A module’s include dirs — its [cpp]’s and every vendored library’s — and the defines of its [cpp] apply to its own generated C++, to the headers it imports, and to every module that depends on it. A header means what the shared defines say, so everything that includes it has to agree; it is also what lets a Dazzle module that wraps a library carry the recipe for its users. Two recipes for one library are an error.

  • A library’s own defines build its sources and nothing else. A single-header library puts its implementation behind one — RGFW_IMPLEMENTATION, STB_IMAGE_IMPLEMENTATION — and it must reach exactly the source that compiles it, or every symbol would be defined again in each translation unit that includes the header. A define that has to hold on both sides, such as the SHLDEF= that gives a header-only library’s declarations external linkage, goes in [cpp].

  • header names a single-header library, and replaces sources for one. Such a library needs exactly one translation unit that includes its header with the implementation define in force, and nothing else about that file is interesting, so dz writes it rather than making you keep a one-line .c beside the header. It is compiled as C for a .h header and as C++ otherwise. Forgetting to write that file by hand produces an unresolved symbol at link time, which says nothing about what is actually missing — this is the same recipe without that trap.

  • cmake names a library that has a CMake build, in place of every key that says how to compile it: root, sources, header, include_dirs, defines, lib_dirs and flags. dz configures, builds and installs it with CMake and its own C and C++ compilers, as a static library, into build/cmake/<name>/. The headers it installed are what import reads, and the pkg-config file it installed (lib/pkgconfig/<name>.pc) gives the link line. options are its -D settings, with true and false passed as ON and OFF. link_libs and frameworks add to what the pkg-config file says, and a target section may add options. Debug builds CMake’s Debug configuration; release and shipping build Release. A library is reconfigured and rebuilt only when a file under cmake, an option, the compilers or the configuration changed, or the project is no longer where it was built: CMake writes absolute paths into its build and its install, so a moved or copied checkout builds the library again. CMake is needed only by a project that has such a recipe. The editor never runs it, so a library’s headers can be read only once a build has installed them in the project’s folder. [DECIDED — D-0090]

    [cpp.libs.sdl3]
    cmake = "vendor/SDL" # a folder with a CMakeLists.txt
    options = { SDL_TEST_LIBRARY = false }
    import = ["SDL3/SDL.h"]
  • A library that needs a configure step and has no CMake build can ship its binaries through lib_dirs and link_libs, or have the configure step’s output generated once and vendored. [DECIDED]

  • import lists the headers Dazzle code sees of the library: their declarations become a module of their own, which the manifest names like any dependency, rl = { cpp = "raylib" } (§3.2). A header is found as #include <raylib.h> would find it, through the include dirs, and the generated C++ includes it by that same name. A library whose API is spread over several headers lists them all — import = ["raylib.h", "raymath.h"] — and they are read together, as one module under one key. A library with no import is built and linked and nothing more. A system library needs no sources, only its link names and the header: [cpp.libs.opengl] with import = ["GL/gl.h"] under target.linux. There is no import cpp in a source file: a header is named once, beside the recipe that builds it, so renaming or replacing a library touches one table. [DECIDED — §18.57]

  • keeps vouches for the library’s functions, and lists the ones that keep a pointer past the call, which stay unsafe to call (§15.2). keeps = [] says none do. Without keeps, a function that takes a pointer is unsafe to call; one that takes only values is safe either way. An entry names a function as Dazzle code calls it, ma_device_init, and a method or a constructor with its type, Body::SetUserData or Counter::Counter; a name the headers do not declare is an error. [DECIDED — D-0094]

    [cpp.libs.miniaudio]
    header = "miniaudio.h"
    import = ["miniaudio.h"]
    keeps = ["ma_device_init"] # the audio thread keeps the device it is given
  • A system header is imported only when it is asked for. raylib.h includes stdlib.h, and importing raylib does not import the C library with it: declarations in a system header are passed over. A recipe that names a system header itself — [cpp.libs.libc] with import = ["stdlib.h"] — gets its declarations, and those of the system headers it includes, since a C library spreads one header’s API over several (malloc is in corecrt_malloc.h on Windows). A system file belongs to the requested header that included it first, so a system header is best requested in a recipe of its own. This is how the standard library reaches the C heap (§14.2). [DECIDED — D-0084]

[dependencies]
rl = { cpp = "raylib" }
jolt = { cpp = "jolt" }
fn main() {
rl::InitWindow(1280, 720, "Dazzle")
defer rl::CloseWindow()
while !rl::WindowShouldClose() {
rl::BeginDrawing()
rl::ClearBackground(rl::RAYWHITE)
rl::DrawText("hello", 16, 16, 20, rl::DARKGRAY)
rl::EndDrawing()
}
}

The compiler runs libclang over the header with the configured include paths and defines, walks the resulting AST, and materialises Dazzle-visible declarations. The generated C++ contains #include "raylib.h" and calls InitWindow(1280, 720, "Dazzle") directly.

A header’s module opens like any dependency (§3.2): rl = { cpp = "raylib", open = true } lets the same program write InitWindow(…) and RAYWHITE bare, with rl:: still available where a name needs telling apart. A C library’s names usually carry their own prefix, which is what makes this reasonable; a header like windows.h, with its thousands of macros, is better left behind a name.

Import results are cached, keyed on the header’s content hash plus the flags, so the libclang pass runs once and not on every build.

C / C++ Dazzle
int, unsigned, float, … i32, u32, f32, … (exact width resolved by libclang)
size_t, uintptr_t / ptrdiff_t, intptr_t usize / isize
T* ^mut T
const T* ^T — read-only, as in C (§18.37)
T& &mut T
const T& &T
T&& not imported: && is C++’s spelling of a move, and §6.4 has its own
const char* ^cstr: C’s pointer, untied; a parameter takes a &cstr or a const &cstr too (T-0112)
struct / class struct, public fields mapped, private and protected C++ members become opaque padding — they stay in the type, since Dazzle never redeclares it, but cannot be named. A field whose type Dazzle cannot name yet is left out the same way. Every field defaults to zero, as {} makes it, so a literal names only the fields it sets
typedef of a struct or enum a second name for the same type
a field of a struct with no name, struct { ma_format format; … } playback; an ordinary field: config.playback.format = …. The struct is imported as ma_device_config_playback (the owner’s name and the field’s), which C++ spells decltype(::ma_device_config::playback); one nested inside it takes the same rule one level down. An anonymous struct { … }; with no field name is different, and its members belong to the enclosing struct
a struct the header only declares a handle: used behind ^T and ^mut T, never by value (§18.40)
C union a type whose members are the same memory, as in C; a literal sets one of them (§18.41)
an array member, float m[16] [16]f32 — indexed, iterated, .len, .ptr, bounds-checked. A whole one is not a value: C cannot copy an array either (DZ0707)
a bit-field, unsigned on : 1 an ordinary member to read and assign. It has no address, so & on one is refused (DZ0706)
a function pointer, void (*)(const RGFW_event *) fn(^RGFW_event) (§5.4): a Dazzle function is handed to C as a callback
member function method — const takes &self, any other &mut self. A static one needs associated functions, which Dazzle does not have yet, and is left out with that reason
constructor a constructor call, Counter(10)
destructor C++ runs it. Dazzle emits no drop for an imported type; the type is move-only instead, so it is never destroyed twice
C++ copy constructor what makes such a type copyable again: a class with a destructor and a public copy constructor is an ordinary value
namespace A::B nested scope: ns::A::B::thing. An inline namespace adds no segment, as in C++
enum enum, and each enumerator also an untyped integer constant at the header’s scope (§18.38)
enum class enum, its enumerators reached only through it
overload set see §15.3
virtual method callable normally; Dazzle types cannot inherit from it
template not imported unless explicitly instantiated (§15.4)
macro #define X 1 constant, emitted by its own name: an integer is untyped, a float f32 when written with f and untyped otherwise (§18.38). A macro whose value is another expression clang can type keeps that type — raylib’s RAYWHITE is a Color
function-like macro not imported
variadic ... not imported; use a fixed-arity overload

The importer only teaches the compiler what exists. A call into a header is emitted as the call it names (::InitWindow(1280, 720, "Dazzle")). Whether it is safe to call depends on what it can be handed, and on what its recipe vouches for (§15.1). [DECIDED — D-0094]

  • A function that takes only values is safe to call. Numbers, enums, a C string, a callback, a handle, and a struct by value with no pointer inside it, checked through every field, cannot carry a pointer to Dazzle memory for C to keep. Most of raylib is like this: InitWindow(1280, 720, "Dazzle"), DrawText(…, DARKGRAY).
  • A function that takes a pointer, a reference, or a struct with one inside it is unsafe to call, and so is a C++ method or constructor, which C++ hands this, until the recipe vouches for the library with keeps. C could keep the pointer after the call, and nothing in Dazzle would notice when what it points at drops.
  • With keeps, every such function is safe to call except the ones it lists, which keep a pointer past the call and stay unsafe. The recipe holds the claim, once, where grep finds it, rather than an unsafe at every call.

A pointer a library keeps that is typed void*, such as a callback’s user data, needs no entry: making a ^mut void from a Dazzle pointer is a cast, which is unsafe already (§5.2). Forming any other pointer is safe as always. A wrapper is seldom needed to make a call pleasant: a String or a Path is passed where the header wants a const char* (§7.2), and the module carrying the recipe may give the header’s types the language’s traits, such as + on a vector struct (§7.6). A Dazzle module wrapping the header is for what a header cannot say, such as which call frees what it returned. What cannot be named in Dazzle yet — a variadic function, a union, a function pointer — is left out of the import rather than guessed at.

An imported type with a non-trivial C++ destructor is move-only in Dazzle unless it also has a copy constructor, which makes C++ RAII types obey Dazzle’s rules automatically.

Imported types can be marked owning at the use site:

struct Texture {
handle: own rl::Texture2D
}
impl Drop for Texture {
fn drop(&mut self) { rl::UnloadTexture(handle) }
}

Dazzle has no function overloading (§7.2), but C++ headers are full of it. Imported overload sets are resolved by argument types at the call site, using exact matches only — no implicit conversions, no promotion, no SFINAE. A type’s ImplicitAs (§7.2) is not tried either: it converts an argument once the function is known, and never chooses one.

An overloaded member function resolves this way today: counter.add(5) and counter.add(point) reach the two adds of one C++ class. An overloaded free function does not yet — the header’s second declaration of a name is passed over, and the first is the one imported.

When two overloads both match exactly, or none does, the error lists the candidates with their parameter types and asks you to disambiguate:

rl::DrawTextureEx@(Texture2D, Vector2, f32, f32, Color)(tex, pos, 0, 1, tint)

The @(...) suffix names a specific signature. It is rarely needed and exists so that no header is ever un-callable. [PROPOSED]

Templates are not mapped onto Dazzle generics. Instead, you name the instantiations you want, and they are imported as ordinary concrete types:

[cpp.instantiate]
"std::vector<float>" = "StdVecF32"
"JPH::Array<JPH::BodyID>" = "BodyIdArray"
[cpp.libs.stdvector]
import = ["vector"]
[dependencies]
cppstd = { cpp = "stdvector" }
let v: cppstd::StdVecF32

The Dazzle name is a name of the header’s module, whatever namespaces the C++ type sits in; the generated C++ writes the instantiation itself. The type’s members are the template’s, with its parameters bound to the arguments named here — so Pair<int>’s sum() returns i32. A member whose type does not follow from that binding (typename T::value_type) is left out with its reason, like any other unmappable declaration.

This covers the real cases (a handful of instantiations per project) without the enormous complexity of unifying two different generic systems. [DECIDED]

#[export]
pub fn dazzle_update(dt: f32) { ... }
#[export(c)]
pub fn dz_init() -> i32 { ... } // extern "C", no mangling

#[export] emits the declaration into the module’s generated header with a stable, documented name so an existing C++ codebase can call into Dazzle — the path for adopting Dazzle inside an engine you already have.