Tutorial 129: Your First C Program Against the Experimental C API

CNA Tutorials  ·  CNA snapshot b0e97bb1  ·  C ABI 0.44.0

ℹ

What you’ll learn: install CNA’s C ABI package (CNACApi), find it from a plain C99 CMake project, and write a complete first program: an ABI check, a game with C callbacks, the borrowed graphics device, a capability query, the count-then-copy string idiom, the error diagnostic and the teardown order.

⚠

Read this first — the C library is source-level, not build-verified. The C API is experimental (ABI 0.44.0, pre-1.0) and belongs to the unreleased development snapshot b0e97bb1, not to a release. At the v0.1.0-alpha.1 tag the C library could not compile at all (its renderer-identity table had 49 rows against 50 identities). That blocker is gone in source, but no CI workflow builds the C library and the authors of this tutorial did not build it. The program below was checked to compile with gcc -std=c99 -Wall -Wextra -Wpedantic -Werror against the headers of an earlier snapshot (ABI 0.29.0); every route and constant it uses is still declared at this snapshot, but it has not been recompiled against these headers; the library steps are written from the CMake files and CNA’s own installed-consumer test, not from a run of ours. If a step fails, that is a real finding: see the limitations and report it.

ℹ

Do not attempt to ship the alpha.1 C library. This tutorial used to be an inspection exercise for exactly that reason: product release 0.1.0-alpha.1 carried a broad C ABI 0.7.0 source surface, but its final C API implementation could not compile because the C renderer table had 49 entries while CNA had 50 identities. The development snapshot corrected that, which is why a real first program is now possible to write. The history is on the C API page.

Prerequisites

You need the development snapshot, not the default branch: git clone https://github.com/libcna/cna.git gives you develop, which is the alpha.1 commit. Clone branch next, and give it the same sibling checkouts Tutorial 02 describes, with one difference from older instructions — sharp-runtime must also be its next branch:

mkdir my-cna-workspace && cd my-cna-workspace
git clone -b next https://github.com/libcna/cna.git
git clone -b next https://github.com/libcna/sharp-runtime.git
git clone https://github.com/libcna/easy-gl.git      # default branch; needed by the Linux default renderer
git clone https://github.com/libcna/meta-gl.git      # default branch; needed by easy-gl
git -C cna submodule update --init

You also need CMake 3.20 or newer, a C compiler and the C++23 toolchain CNA itself needs (the C library is an adapter over the C++ runtime). The C API requires networking to stay enabled (CNA_ENABLE_NET=ON is the default), because it exports the GamerServices routes.

Build and install the C package

Configure CNA with the C API switched on, build the shared library (and the optional static archive, which is part of the default build), then install only the CNACApi component into a prefix of your own:

cd cna
cmake -S . -B build -DCNA_BUILD_C_API=ON
cmake --build build --target cna_c_api cna_c_api_static -j"$(nproc)"
cmake --install build --component CNACApi --prefix "$HOME/cna-c"

The component contains the shared library libcna_c_api.so, the public headers under include/CNA/C/, the SDL3 libraries CNA built (installed beside the library, whose RPATH is $ORIGIN, so you need no LD_LIBRARY_PATH), and the CMake package under lib/cmake/CNA. To skip the static archive pass -DCNA_C_API_BUILD_STATIC=OFF and build only cna_c_api.

ℹ

No display? Add -DCNA_PLATFORM=HEADLESS -DCNA_GRAPHICS_RENDERER=HEADLESS -DCNA_AUDIO_PLATFORM=NULL to the configure line for a window-less build. If you keep a windowed platform on a machine with no display, only renderers that need no GL context run under SDL’s dummy video driver (SDL_VIDEODRIVER=dummy).

Write the consumer project

Create a project that knows nothing about the CNA source tree. It needs only the prefix:

mkdir my-c-game && cd my-c-game

CMakeLists.txt:

cmake_minimum_required(VERSION 3.20)
project(my_c_game LANGUAGES C)

# The package version *is* the C ABI version, and CMake accepts any same-major version at or
# above the one requested. Request the minor you wrote against and leave it there.
find_package(CNA 0.1 CONFIG REQUIRED)

add_executable(my_c_game main.c)
set_target_properties(my_c_game PROPERTIES
  C_STANDARD 99            # the floor CNA holds its public headers to
  C_STANDARD_REQUIRED ON
  C_EXTENSIONS OFF)
target_compile_options(my_c_game PRIVATE
  $<$<NOT:$<C_COMPILER_ID:MSVC>>:-Wall;-Wextra;-Wpedantic>)
target_link_libraries(my_c_game PRIVATE CNA::CApi)   # or CNA::CApiStatic for one archive

There is exactly one target for the shared library, CNA::CApi, and one for the archive, CNA::CApiStatic (which defines CNA_C_API_STATIC for you). Every C++ module is linked into the library privately, behind hidden visibility and a version script, so a C consumer never sees cna_core, cna_graphics or Sharp Runtime. Without CMake, the same library links with cc -std=c99 -I"$HOME/cna-c/include" main.c -L"$HOME/cna-c/lib" -lcna_c_api.

Write main.c

The program does what every first CNA C program has to do, in the order it has to do it. Read the comments; each numbered step is a rule of the ABI.

/* main.c -- a first CNA C program: version check, game, callback, renderer query, teardown. */
#include <CNA/C/cna.h>

#include <stdio.h>
#include <string.h>

typedef struct AppState {
    int frames;
    char renderer[128];
} AppState;

/* Every fallible route returns a CNA_Result. This helper prints the diagnostic the ABI keeps
   for the failing thread, using the count-then-copy idiom, and returns non-zero on failure. */
static int failed(const char* what, CNA_Result result)
{
    CNA_ErrorInfo info;
    char message[512];
    uint64_t needed = 0U;

    if (result == CNA_RESULT_SUCCESS) {
        return 0;
    }
    fprintf(stderr, "%s failed with result %u", what, (unsigned)result);

    memset(&info, 0, sizeof(info));
    info.struct_size = (uint32_t)sizeof(info);
    info.struct_version = UINT32_C(1);
    if (cna_error_get_last_info(&info) == CNA_RESULT_SUCCESS) {
        fprintf(stderr, " (category %u)", (unsigned)info.category);
    }
    if (cna_error_copy_last_message(message, sizeof(message) - 1U, &needed) == CNA_RESULT_SUCCESS) {
        message[needed] = '\0';
        fprintf(stderr, ": %s", message);
    }
    fprintf(stderr, "\n");
    return 1;
}

/* Runs once per frame on the game's thread. `game` is borrowed for the duration of the call. */
static CNA_Result on_update(
    CNA_Handle game,
    const CNA_GameTime* game_time,
    void* context,
    CNA_CallbackError* out_error)
{
    AppState* state = (AppState*)context;
    (void)game_time;
    (void)out_error;

    if (state->frames == 0) {
        CNA_Handle device = CNA_INVALID_HANDLE;
        CNA_RendererInfo info;
        CNA_Bool three_d = CNA_FALSE;
        uint64_t name_bytes = 0U;
        CNA_Result result;

        /* The graphics device may only be borrowed inside a lifecycle callback and must
           never be released by the caller. */
        result = cna_game_get_graphics_device(game, &device);
        if (result != CNA_RESULT_SUCCESS) return result;

        memset(&info, 0, sizeof(info));
        info.struct_size = (uint32_t)sizeof(info);
        info.struct_version = UINT32_C(1);
        result = cna_graphics_device_get_renderer_info(device, &info);
        if (result != CNA_RESULT_SUCCESS) return result;

        /* Count, then copy: the count excludes any terminator, so leave room for one. */
        result = cna_graphics_device_copy_renderer_name(
            device, state->renderer, sizeof(state->renderer) - 1U, &name_bytes);
        if (result != CNA_RESULT_SUCCESS) return result;
        state->renderer[name_bytes] = '\0';

        /* Ask what the renderer can do instead of branching on its name. */
        result = cna_graphics_device_supports_capability(
            device, CNA_GRAPHICS_CAPABILITY_THREE_D, &three_d);
        if (result != CNA_RESULT_SUCCESS) return result;

        printf("renderer: %s (max texture %u, 3D %s)\n", state->renderer,
               (unsigned)info.max_texture_dimension,
               three_d == CNA_TRUE ? "supported" : "unavailable");
    }

    state->frames++;
    if (state->frames >= 3) {
        return cna_game_request_exit(game);   /* ends cna_game_run() at its next safe point */
    }
    return CNA_RESULT_SUCCESS;
}

int main(void)
{
    const uint32_t runtime_abi = cna_get_abi_version();
    static const char title[] = "First C program";
    AppState state;
    CNA_GameCallbacks callbacks;
    CNA_GameCreateInfo create_info;
    CNA_Handle game = CNA_INVALID_HANDLE;
    CNA_Result result;

    /* 1. Check the ABI before anything else. A different major is never compatible. While the
          ABI is 0.x, also require the exact minor you compiled against. */
    if (runtime_abi != CNA_ABI_VERSION) {
        fprintf(stderr, "CNA ABI mismatch: built against %u.%u.%u, found %u.%u.%u\n",
                (unsigned)CNA_ABI_VERSION_MAJOR, (unsigned)CNA_ABI_VERSION_MINOR,
                (unsigned)CNA_ABI_VERSION_PATCH, (unsigned)(runtime_abi >> 16U),
                (unsigned)((runtime_abi >> 8U) & 0xFFU), (unsigned)(runtime_abi & 0xFFU));
        return 1;
    }
    printf("CNA C ABI %u.%u.%u\n", (unsigned)(runtime_abi >> 16U),
           (unsigned)((runtime_abi >> 8U) & 0xFFU), (unsigned)(runtime_abi & 0xFFU));

    /* 2. Create the process's one game. Versioned structs: zero, then size and version. */
    memset(&state, 0, sizeof(state));
    memset(&callbacks, 0, sizeof(callbacks));
    callbacks.struct_size = (uint32_t)sizeof(callbacks);
    callbacks.struct_version = UINT32_C(1);
    callbacks.update = on_update;
    callbacks.context = &state;

    memset(&create_info, 0, sizeof(create_info));
    create_info.struct_size = (uint32_t)sizeof(create_info);
    create_info.struct_version = UINT32_C(1);
    create_info.is_fixed_time_step = CNA_TRUE;
    create_info.target_elapsed_time_ticks = INT64_C(166667);   /* 100 ns ticks: about 60 Hz */
    create_info.window_title.data = title;
    create_info.window_title.byte_length = sizeof(title) - 1U;
    create_info.callbacks = &callbacks;

    result = cna_game_create(&create_info, &game);
    if (failed("cna_game_create", result)) return 1;

    /* 3. Let CNA own the loop until the callback asks it to exit. */
    result = cna_game_run(game);
    (void)failed("cna_game_run", result);

    /* 4. Two deliberate mistakes: the ABI tells an argument error from a handle error. */
    {
        CNA_Bool active = CNA_FALSE;
        (void)failed("cna_game_get_is_active(live handle, NULL output)",
                     cna_game_get_is_active(game, NULL));
        (void)failed("cna_game_get_is_active(handle never issued)",
                     cna_game_get_is_active(UINT64_C(0xDEADBEEF), &active));
    }

    /* 5. Teardown: owned children first (this program created none), then the game. */
    if (failed("cna_game_destroy", cna_game_destroy(game))) return 1;

    printf("done after %d frame(s)\n", state.frames);
    return result == CNA_RESULT_SUCCESS ? 0 : 1;
}
StepRule it demonstrates
1. ABI checkCompare cna_get_abi_version() with CNA_ABI_VERSION before anything else. CNA’s own hello_cna accepts any equal-or-newer minor of the same major; while the ABI is 0.x a minor step can be incompatible (0.30.0 through 0.35.0 all were), so this program insists on the exact version it was built against.
2. CreateVersioned structs start with struct_size and struct_version; zero them first. The callback table is copied during creation, the context pointer must outlive the game. One active game per process (a second cna_game_create answers CNA_RESULT_INVALID_STATE).
CallbackCallbacks run synchronously on the game’s thread. The game handle is borrowed for the call. The graphics device may be borrowed inside a lifecycle callback — and, since ABI 0.42, right after cna_game_create on the creating thread until the first callback returns, and since 0.44 in Activated/Deactivated handlers — and that handle is borrowed too: never release it. Returning anything but CNA_RESULT_SUCCESS stops the game and the enclosing call reports CNA_RESULT_CALLBACK. Never throw or longjmp out of it.
Count-then-copycna_graphics_device_copy_renderer_name and cna_error_copy_last_message write into your buffer and report the required count, which excludes any terminator. A buffer that is too small is refused with CNA_RESULT_BUFFER_TOO_SMALL and nothing is partially written.
CapabilityAsk cna_graphics_device_supports_capability what the renderer can do; never branch on its name or numeric identity.
3. Runcna_game_run owns the loop until cna_game_request_exit. For a host that owns the loop, cna_game_run_frame_ext (ABI 0.38) drives the same run one frame per call, including begin_run, exiting and end_run; the older cna_game_run_one_frame never begins or ends a run.
4. Two deliberate mistakesA null output is an argument failure (result 1, category 1); a handle that was never issued is a handle failure (result 2, category 2). Arguments are checked before handles, and neither call touches anything.
5. TeardownDestroy owned children first, then the game. A game refuses to be destroyed while an owned child is alive, which is a diagnosable error rather than a crash.

Configure, build and run

cmake -S . -B build -DCMAKE_PREFIX_PATH="$HOME/cna-c"
cmake --build build
./build/my_c_game

You should see the ABI line, the renderer line and, at the end, a frame count; the two deliberate failures print on stderr. The exact renderer name and texture limit depend on the renderer and platform you configured:

CNA C ABI 0.44.0
renderer: <the active renderer’s name> (max texture <n>, 3D supported|unavailable)
cna_game_get_is_active(live handle, NULL output) failed with result 1 (category 1): <message>
cna_game_get_is_active(handle never issued) failed with result 2 (category 2): <message>
done after 3 frame(s)

(A windowed build opens a window for a moment. Message texts are CNA’s; the result and category numbers above are the contract: CNA_RESULT_INVALID_ARGUMENT is 1 and CNA_RESULT_INVALID_HANDLE is 2.)

If something goes wrong

SymptomLikely cause and what to check
CMake configure of CNA stops with “CNA_BUILD_C_API=ON requires CNA_ENABLE_NET=ON”Networking was switched off. Leave CNA_ENABLE_NET at its default ON.
find_package(CNA) cannot find the packageCMAKE_PREFIX_PATH must be the install prefix ($HOME/cna-c), and the component must have been installed with --component CNACApi.
The program prints “CNA ABI mismatch”The library you loaded was built from a different snapshot than the headers you compiled against. Use headers and library from one prefix.
Loader cannot find libSDL3The SDL3 libraries ship in the same directory as libcna_c_api.so; a prefix assembled by hand may have dropped them.
cna_game_create fails with result 3A game already exists in this process. There is one active game per process.
The library build itself failsNot verified by us at this snapshot; please report it with your compiler and configure line. Remember that the C ABI is experimental.

What to check before you rely on it

  • You built cna_c_api yourself from the exact snapshot you are documenting against; no CI job does, and nothing here is a release artifact.
  • Your program pins the ABI it was written against and refuses a different major or minor, since 0.x minors can be incompatible (0.30.0 through 0.35.0 were).
  • The installed-consumer test that CNA runs locally (CApi_InstalledConsumer) passes for you: it installs the component, builds CNA’s own hello_cna shared and static from a standalone project, and runs both.
  • You call the C API only from the thread that created the game, and you destroy children before the game. The documented exceptions are opt-in or narrow: the foreign-thread call queue (cna_game_set_foreign_thread_calls_ext, ABI 0.39, drained with cna_game_run_foreign_thread_calls_ext, 0.41) and cna_sound_effect_play/_play_with_settings on any thread (0.43).

The complete source-owned example, modules/c-api/examples/c/hello_cna.c (354 lines), goes further than this tutorial: it creates a 2×2 texture, uploads pixels and draws them with a sprite batch, and treats CNA_RESULT_NOT_SUPPORTED from a renderer that cannot draw as an answer rather than a failure.

ℹ

What is still measurable: this snapshot declares 60 public C headers and 3,210 cna_* routes (alpha.1: 59 and 2,861), and CNA’s generated inventory records 8,142 symbols — 7,030 implemented, 15 partial, 656 planned, 441 not applicable. The release gate for the C ABI says Not ready: nine of its ten criteria are met, and the unmet one is the 656 public symbols that have no C mapping yet (see the status table). These are source and generated-report counts, not proof of a linkable or runnable library.

Next steps