C API internals

CNA snapshot 009d40f5  ·  Development › C API & bindings internals  ·  source links pinned to 009d40f5

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Read at 009d40f5 only. CNA_BUILD_C_API defaults to OFF and no CI workflow builds the library; no build or C API test was run for this page.

The C surface is not a thin symbol rename of C++ CNA. It is a second ownership model: versioned C structures, canonical UTF-8, string and boolean rules, result codes with a thread-local diagnostic, generation-checked typed handles, child-before-parent destruction gates and a lifecycle-callback adapter. This page traces how modules/c-api implements those rules at the TARGET snapshot, for maintainers who change a C route and for anyone who writes or reviews a language binding, which inherits every one of these choices even when its wrapper looks object-oriented.

⚠

Source-level only. Everything here was checked by reading modules/c-api at 009d40f5; nothing was built or executed. The option CNA_BUILD_C_API defaults to OFF, no CI workflow builds the library, and CNA's own release gate for ABI 0.29.0 reads “Not ready”. The user-facing picture (build, package, version history, coverage, gates) is on the Native C API guide; this page does not repeat it.

Build artifact and public boundary

cna_c_api (alias CNA::CApi) is compiled from 59 feature-family translation units, CnaCApi*.cpp, listed explicitly in modules/c-api/CMakeLists.txt; the other files under modules/c-api/src are private *Detail.hpp headers shared between families. The 61 public headers under modules/c-api/include/CNA/C are aggregated by cna.h. modules/c-api/include/CNA/C/abi.h carries the experimental ABI version (0.29.0: CNA_ABI_VERSION_MAJOR 0, _MINOR 29, _PATCH 0, packed by CNA_ABI_VERSION_ENCODE as major<<16 | minor<<8 | patch), the fixed-width CNA_Result (uint32_t, codes 0–14), CNA_Bool (uint8_t), CNA_Handle (uint64_t, CNA_INVALID_HANDLE = 0), the CNA_C_API export macro and cna_get_abi_version().

Two language levels apply and should not be confused. The public headers are held to a C99 consumer floor (the compatibility matrix in tools/c-api/compatibility_matrix.json requires c99/c11/c17 and c++11/14/17 cells; the shipped example is compiled at exactly C99), while CNA's own C test targets compile at C17 with extensions off and -Wall -Wextra -Wpedantic -Werror (/W4 /WX on MSVC).

ArtifactWhenHow the surface is controlled
Shared library libcna_c_apiEvery non-Emscripten build with CNA_BUILD_C_API=ONCXX_VISIBILITY_PRESET hidden and VISIBILITY_INLINES_HIDDEN; CNA_C_API marks routes visibility("default") (GCC/Clang) or dllexport/dllimport (Windows). On ELF only (UNIX AND NOT APPLE AND NOT EMSCRIPTEN) the link adds --exclude-libs,ALL and the version script CnaCApiExports.map: cna_* global, everything else local. INSTALL_RPATH "$ORIGIN".
Static archive CNA::CApiStaticOption CNA_C_API_BUILD_STATIC, Linux-like hosts with Python 3generate_static_archive.py partially links the closure into one object and localizes every non-cna_* symbol; consumers get CNA_C_API_STATIC, which empties the export macro.
Wasm module cna_c_api_wasmEmscripten (the library itself is then STATIC)An ES module factory (cna_c_api.mjs + .wasm, createCnaCApi) whose -sEXPORTED_FUNCTIONS list is generated from the public headers by generate_wasm_exports.py; the headers are declared as inputs so a newly declared route cannot be silently missing. -sASYNCIFY=0: JavaScript owns the loop and calls the one-frame route (cna_game_run_one_frame) from requestAnimationFrame. module_entry.c pins the wasm32 layout of CNA_GameCallbacks and CNA_GameCreateInfo with _Static_asserts.

The ELF symbol-version node is CNA_C_API_0.1 and is intentionally not the semantic ABI version: already-linked consumers record the node name, so renaming it on every minor would turn each additive release into a hard break (the map file's own comment says it changes only for a major break). These are three distinct final artifacts, not one library that works unchanged everywhere, and a route change has to be checked against each of them.

Route anatomy and the exception barrier

A fallible route follows one shape: validate the C-facing arguments, clear every out_ handle to CNA_INVALID_HANDLE, run the body inside CallWithExceptionBarrier, resolve typed native objects through the handle registry, then translate results back into POD values or caller-owned buffers. The order of the first two steps is itself a contract: a refused creation must leave its output invalid, which is why CnaCApiDetail.hpp tells authors to call ValidateCanonicalBool only after clearing the output.

CallWithExceptionBarrier is a noexcept template whose catch arms are ordered from most derived to most general, so C++ exceptions never cross the ABI. The mapping at TARGET, in arm order:

Caught (in order)CNA_Result
std::bad_allocOUT_OF_MEMORY
std::overflow_error, std::range_errorOVERFLOW
std::out_of_range, std::invalid_argumentINVALID_ARGUMENT
other std::logic_errorINVALID_STATE (deliberately not INTERNAL)
std::ios_base::failure, std::filesystem::filesystem_error, System::IO::IOException, ContentLoadExceptionIO
StorageDeviceNotConnectedException, InstancePlayLimitException, gamer-privilege / guide-visible, sensor failure, network-session join failure, network not availableINVALID_STATE (sensor and join failures also record their error id in the thread's diagnostic)
NoAudioHardwareException, NoMicrophoneConnectedException, gamer services not available, game update requiredNOT_SUPPORTED
other NetworkException, CNA::Platform::PlatformExceptionPLATFORM
CNA::Graphics::EngineException (CNAEXT builds only)NOT_SUPPORTED
CNA::CNAException, System::InvalidOperationException, DeviceLostException, DeviceNotResetExceptionINVALID_STATE
System::ArgumentExceptionINVALID_ARGUMENT
System::NotImplementedException, System::NotSupportedException, NoSuitableGraphicsDeviceExceptionNOT_SUPPORTED
any other std::exception; ...INTERNAL

Because the arms are positional, changing the native exception hierarchy (a new base class, a re-parented exception) can silently move a failure into a different arm; PlatformException, for example, derives from std::runtime_error and must stay above the generic arm. A new native exception type therefore needs a C-ABI behaviour review, and BoundaryDetailTest.cpp (CApi_BoundaryDetail) pins the mapping for representative exceptions (argument, allocation, not-supported, device-lost family, unknown failure) together with the error-copy and string helpers.

Every arm goes through Fail, which writes a thread_local LastError (result, category, message, plus the optional join-error and sensor-error ids) in CnaCApiDetail.cpp. A successful call does not clear it; the three cna_error_* queries in core.h never overwrite it. CNA_ErrorCategory is derived from the result by ErrorCategoryForResult (OVERFLOW and BUFFER_TOO_SMALL share RANGE).

Opaque handle registry and actual ownership

CNA_Handle (uint64_t)
  bits 63..32  generation   1 .. UINT32_MAX, bumped on every Release, wraps to 1 (never 0)
  bits 31..0   slot index + 1  (0 is never a valid slot; handle 0 = CNA_INVALID_HANDLE)

HandleRegistry::Slot { generation, ObjectKind kind, shared_ptr<void> object,
                       std::thread::id creationThread, uint64_t userTag }

There is exactly one registry per process: GetRuntimeHandles() returns the HandleRegistry inside the static RuntimeState of CnaCApiRuntime.cpp. Create reuses the first slot whose object is empty (a linear scan) or appends a new one, records the kind, the calling thread and a zero user tag, and encodes the handle from the slot's current generation. Get<T> takes the registry mutex, rejects handle 0, slot 0, generation 0, an out-of-range slot, an empty slot or a generation mismatch (INVALID_HANDLE), then a different ObjectKind (INVALID_HANDLE), then a different creation thread (THREAD), and only then static_pointer_casts the stored shared_ptr<void>. Release performs the same lookup and thread check, moves the object out, clears the slot and bumps the generation; the moved-out shared_ptr is destroyed after the lock scope ends, so a destructor that re-enters the registry cannot deadlock. A stale handle cannot resolve to a reused slot until that slot's 32-bit generation has cycled.

Three consequences matter to binding authors. First, the kind check is the only type guard: a route that names the wrong C++ type for a kind would be undefined behaviour, which is why, for instance, every audio event registration is stored under one base class (AudioRegistrationBase in CnaCApiAudioDetail.hpp). ObjectKind enumerates 181 kinds (1–181) plus Unknown and a test-only value. Second, every handle resolved through Get<T> or released through Release is creation-thread-affine, not only some; only the internal GetKind query skips the thread check. Third, the mutex makes registry bookkeeping coherent; it does not make any CNA object thread-safe, and the per-resource counters below are plain integers protected only by that thread affinity.

What a handle actually owns

A handle's existence does not imply that it owns the native object. The shapes that coexist at TARGET (declared in CnaCApiRuntimeDetail.hpp and the family files):

ShapeExampleLifetime rule
Owned resource with parent tokenSoundEffectResource, Texture2DResourceHolds the native object by shared_ptr plus parentGame; destroyed by its own route; counted against cna_game_destroy.
Owned child with parent referenceSoundEffectInstanceResourceHolds a shared_ptr to the parent resource and increments the parent's child count; the parent refuses destruction until it is zero.
Borrowed game deviceBorrowedGraphicsDeviceA raw GraphicsDevice* plus an owner token (the game handle). Created lazily during a lifecycle callback and released when that callback returns.
Caller-created deviceOwnedGraphicsDeviceA unique_ptr<GraphicsDevice> plus a BorrowedGraphicsDevice view whose owner token is the device's own handle, so resources on two standalone devices are as distinguishable as two games' resources. Not counted against game destruction.
Game content-manager borrowcna_game_get_content_manager_extOne cached handle over the game's value-member ContentManager (a non-owning aliasing shared_ptr); cna_content_manager_destroy refuses it with INVALID_STATE; released with the game.
Callback-scoped borrowBorrowContentManagerForCallbackA fresh handle over whichever manager performs a .cnb load, wrapped with a no-op deleter and released by its creator before the loader callback returns.
Resource-to-resource referencesTexture2DResource active batch/font/effect/model/scope countscna_texture2d_destroy answers INVALID_STATE while a live sprite batch, font, effect, model or scope still references the texture.

Resource routes copy the owner token and compare it to reject cross-device use. These wrappers cannot be treated interchangeably by a binding. Before adding a route, decide whether its result is owned, shared, borrowed from a parent, callback-scoped or a copied value, and write the exact destroy and parent rules into the public header. The whole-engine ownership picture is on Ownership and lifetime master map.

Representative binding call: C game creation and one frame

language wrapper → cna_game_create(CNA_GameCreateInfo*, out handle)
  → CallWithExceptionBarrier
  → struct_size >= sizeof, struct_version == 1; out handle cleared
  → canonical is_fixed_time_step, ticks > 0, callbacks table valid or null
  → window title: valid UTF-8, embedded NUL refused, copied
  → RuntimeState lock: refuse if hasActiveGame (INVALID_STATE)
  → make_shared<CGame>(callbacks copied) → timing and title set
  → HandleRegistry::Create(ObjectKind::Game) → hasActiveGame = true
cna_game_run_one_frame(handle)            (also cna_game_run, cna_game_tick)
  → GetCallableGame: kind + creation thread + not inside a callback
  → Game::RunOneFrame → CGame overrides → C callbacks
  → returns the recorded callback failure (or SUCCESS)
cna_game_destroy(handle)
  → GetCallableGame → owned-child gates → CGame::Shutdown
  → adapter/state resets → Release handle → hasActiveGame = false

CGame derives from the native Game and translates its virtuals into the C table CNA_GameCallbacks (load_content, update, draw, unload_content, exiting, one context) declared in runtime.h. Its Update and Draw call the C hook first and then Game::Update / Game::Draw, so game components still tick and draw and FrameworkDispatcher still advances (a comment in the source records that the base call was once missing). The optional frame hooks installed with cna_game_set_frame_hooks_ext (initialize, begin_run, end_run, begin_draw, end_draw) follow the canonical subclass shape: initialize runs before Game::Initialize, which is what ends by calling LoadContent, so a C game sees initialize before load_content as a ported XNA game expects; begin_draw can veto the frame through an out CNA_Bool.

Every callback invocation is itself run inside CallWithExceptionBarrier with isInsideCallback_ set. A non-success return (or a C++ exception from a C++-implemented callback) is recorded by RecordCallbackFailure: the optional CNA_CallbackError message (versioned, UTF-8, borrowed until the callback returns) becomes the thread's diagnostic with CNA_RESULT_CALLBACK, the failure is latched and Exit() is called. No C error code is thrown through native game execution; once latched, later callbacks and the base Update/Draw passes are skipped, and cna_game_run, _run_one_frame and even cna_game_destroy return the latched result.

A lifecycle callback may borrow the graphics device with cna_game_get_graphics_device; outside a callback that route answers INVALID_STATE, and the borrowed handle is released when the callback returns. GetCallableGame refuses to re-enter run, run_one_frame, tick or destroy from inside a callback (INVALID_STATE), while request_exit, clear, set_window_title and set_frame_hooks_ext are allowed there. The process admits one C-owned active game at a time; standalone devices from cna_graphics_device_create are a separate rule and do not need a game.

What cna_game_destroy does, in order

  1. Resolve the game through GetCallableGame (kind, thread, not inside a callback).
  2. Refuse with INVALID_STATE while any C-owned game graphics resource, owned content manager, owned audio resource or owned game component is still alive. The four counters live in the process-wide RuntimeState; graphics resources are counted only when their owner token is a game (AddOwnedGraphicsResourceFor).
  3. CGame::Shutdown: the exiting callback (once), unload_content if content was loaded, then Dispose(), so subscribers still observe the game's own Disposed event, the disposal of its components (Disposed) and of the graphics device manager (Disposed and DeviceDisposing); the content manager is disposed too but has no event to observe. It does not dispose the game's canonical GraphicsDevice: Game::Dispose(true) never does, and the device raises Disposing only from its destructor once the game object itself goes away, after the adapter state below has been reset. The source comment after the Shutdown call that says otherwise is inaccurate.
  4. Drop the C-side state that referred into the game: graphics-device adapter state (event subscriptions, recorded sampler bindings), the platform override, game and window event registrations, the graphics device managers retained past their handles, and the borrowed game content-manager handle. The manager retention is deliberate: native Game caches a raw pointer to the graphics device service, so releasing a manager's handle cannot free the manager while the game lives.
  5. Release the game handle, clear hasActiveGame and return the latched callback result.

A wrapper finalizer that destroys the game before its child handles violates this API even if the host garbage collector later frees the children. The native shutdown sequence the adapter drives is traced on Ownership and shutdown.

Representative child call: SoundEffect and instance

cna_sound_effect_create_pcm16 in CnaCApiAudio.cpp clears its output, then validates the versioned CNA_SoundEffectCreateInfo (size, version 1, zero reserved, sample rate in 1..INT32_MAX, channels exactly mono or stereo), the pointer/count pair through CheckedElementByteCount, a byte count that is nonzero, at most INT32_MAX and a whole number of 16-bit frames, and finally the game handle (ValidateActiveGameHandle, which also enforces the creation thread). It copies the caller's bytes into a native std::vector (so the language buffer need not stay pinned), constructs the native SoundEffect (a NoAudioHardwareException becomes NOT_SUPPORTED), wraps it in SoundEffectResource{value, parentGame, instanceCount = 0}, creates a SoundEffect-kind handle and increments the process's owned-audio count. cna_sound_effect_create_pcm16_range_ext, the route the C#, Java and Python binding pages trace at their pinned revisions, adds signed offset/count/loop checks against the buffer before the same copy and publishes through the shared PublishSoundEffect helper; content-loaded effects are published by the same factory (CreateOwnedSoundEffect), so every path yields the same kind of owned game child.

cna_sound_effect_create_instance resolves the parent, constructs a native SoundEffectInstance, wraps it with a shared_ptr to the parent resource, and increments both the parent's instanceCount and the owned-audio count. cna_sound_effect_destroy refuses with INVALID_STATE while instanceCount is nonzero, otherwise calls native Dispose, releases the handle and decrements the count. cna_sound_effect_instance_destroy disposes, releases, decrements its parent's count (a streaming instance has no parent) and the owned-audio count. This is stronger ordering than “shared_ptr keeps memory alive”: it preserves the public lifecycle contract and feeds the game-destroy gate.

Rules for a binding that wraps this sequence:

  • Keep the exact CNA_Handle as a 64-bit integer; never reinterpret it as a native pointer.
  • Do not destroy children from an arbitrary garbage-collector or finalizer thread: the route answers CNA_RESULT_THREAD and the handle stays live.
  • Check the result before reading an output handle; a failed create leaves it CNA_INVALID_HANDLE.
  • Map host exceptions from CNA_Result plus the current thread's last diagnostic, read immediately. The store is thread_local, so a reader on another host thread does not see the failure, and a later failure on the same thread replaces it.
  • The message copy is size query followed by caller-owned buffer (cna_error_get_last_message_size, cna_error_copy_last_message); keep both calls on the failing thread and handle BUFFER_TOO_SMALL, which writes nothing and reports the required size.

The pure-C test that pins this contract is AudioSmoke.c (CApi_AudioSmoke): with an instance alive it expects cna_game_destroy and cna_sound_effect_destroy to answer INVALID_STATE, from a second thread it expects THREAD for a query and for instance destruction, and afterwards child-first destruction, INVALID_HANDLE on a double destroy and on use of the destroyed game. AudioSoundEffectSmoke.c (CApi_AudioSoundEffectSmoke) covers the extended creation routes (range, encoded, asset), process-wide 3D-audio settings and cna_sound_effect_play. The native voice and mixer path behind these handles is on Audio engine internals.

Validation, ABI evolution and tests

Conventions every route enforces

  • Versioned structures. The routes read here accept a structure when struct_size >= sizeof(the current struct) and struct_version == 1; a smaller prefix is refused with INVALID_ARGUMENT, a larger future struct passes the check. Validators must check both before reading a new field.
  • Strings. CNA_StringView is a borrowed {data, byte_length} span valid for one call, not a NUL-terminated or long-lived string. ValidateStringView accepts data == NULL only with length 0, rejects overlong encodings, surrogates and code points above U+10FFFF (ENCODING), and optionally rejects embedded NUL; CopyStringView validates before copying. The rules are cross-checked by Utf8OracleTest.cpp (CApi_Utf8Oracle) and a libFuzzer source compiled but not registered as a test.
  • Booleans. Only CNA_FALSE (0) and CNA_TRUE (1) are valid; ValidateCanonicalBool names the offending parameter. Since ABI 0.3.0 every route taking one refuses other bytes; the generated CApi_BoolContractSmoke test and the CApiBoolContractCurrent gate keep that true.
  • Buffers. CheckedElementByteCount multiplies element count by element size with overflow checks against both uint64_t and size_t (OVERFLOW) before any caller memory is touched.

Changing any of these conventions affects every consumer at once: C programs, the external C#, Java, Python, Rust, TypeScript, Swift, Go and Ruby projects, and wasm callers.

Adding or changing a route

  1. Decide whether the C++ addition belongs in the C ABI at all (CNA's coverage model records some public C++ symbols as not applicable to C).
  2. Declare it in the owning public header with its ownership, thread and result contract written out.
  3. Implement it in the owning CnaCApi*.cpp family: output cleared first, CallWithExceptionBarrier, handle kind and thread through the registry, validation as above, child counts if it creates a child.
  4. Apply the ABI version policy in ABI_VERSIONING.md: while 0.x, an incompatible change needs a minor increment, release notes and a regenerated abi_baseline.json (the CMake package version is read out of abi.h).
  5. Audit the exported surface: CApiDeclaredExports (check_declared_exports.py, declared vs exported in both directions), CApi_Exports (ELF export check), the generated wasm export list, and CApiRouteTestCoverage, whose budget of uncovered routes is 0, so the new route must be named by a test or example.
  6. Add pure-C tests under tests/pure_c plus focused boundary or handle tests, then run the installed-consumer check.
  7. Only then update each external binding, at a recorded revision.

A route added to the header and C++ but missing from the wasm export list is invisible to browser callers; the generator exists precisely because that failure produces no build diagnostic. Test the final artifacts, not just static compilation. The step-by-step maintainer recipe is I need to update the C API.

Tests and gates to know

EvidenceWhat it checksWhere it runs
AbiHeaderC.c, AbiHeaderCpp.cppEvery public header compiles as C17 and as C++ under strict warnings (object libraries, not CTest entries)C API builds
CApi_HandleRegistry, CApi_BoundaryDetail, CApi_Utf8OracleRegistry user tags, off-thread THREAD refusal, double release (INVALID_HANDLE) and slot reuse under a new generation; representative barrier mappings and the error-copy/string helpers; the UTF-8 oracle (C++ test programs, not GoogleTest)C API builds
85 pure-C programs (CApi_*Smoke, CApi_TeardownLifetime_*, CApi_AudioSmoke, CApi_RuntimeGameSmoke, CApi_OwnedGraphicsDeviceSmoke …)Route behaviour, lifetime and refusal contracts from real CC API builds
CApi_InstalledConsumerInstall the CNACApi component, build hello_cna shared and static from outside the tree, run bothLocal CTest, Linux/ELF only
CApiAbiHeaderBaseline, CApiDeclaredExports, CApiRouteTestCoverage, CApiBoolContractCurrent, CApiReleaseGate and the other header/JSON gates in ModuleProbes.cmakeRecorded layouts and constants, export sets, route-test coverage, release verdictOrdinary build (no C library needed for most)

None of these was executed for this page; CNA's own records of a ctest -R '^CApi' run are summarised on the C API guide's gates section. The wider test layout is on Test architecture.

Known evidence gaps

  • This page establishes the in-tree ABI at source level. It does not establish that the library builds at 009d40f5, nor binary compatibility of any external binding. The public bindings target ABI 0.21.x, while TARGET exports 0.29.0 after two incompatible steps (0.28.0, 0.29.0); see Bindings boundary and the C#, Java and Python binding internals, each pinned to its own revision.
  • Some routes have callback, thread or borrowed-resource rules beyond this general model (event registrations, .cnb loader callbacks, streaming audio, storage). Read the route's header and implementation rather than extrapolating from the SoundEffect example.
  • cna_graphics_device_destroy for a caller-created device disposes and releases it without consulting a child count in its route body; graphics_device.h documents that the device's resources are released with it (GraphicsDevice::Dispose disposes its resource list), so destroying the device first is permitted. The pure-C smoke destroys resources first, and no test pins what a child handle answers after its device is gone.

Source reading order

  1. abi.h, core.h and runtime.h: wire types, error queries and the game ownership statements before any implementation.
  2. CnaCApiDetail.hpp and CnaCApiDetail.cpp: exception arms, LastError, string/bool/buffer checks, handle encoding, generation and thread enforcement.
  3. CnaCApiRuntime.cpp and CnaCApiRuntimeDetail.hpp: CGame, callback latching, borrowed devices, the child counters and the destroy sequence.
  4. CnaCApiAudio.cpp with AudioSmoke.c and AudioSoundEffectSmoke.c: one owned parent/child family against its C-only tests.
  5. CnaCApiContent.cpp (borrowed and callback-scoped content managers) and CnaCApiDisplay.cpp (caller-created devices): the two ownership shapes that differ most from the audio example.
  6. c-api CMakeLists.txt and CnaCApiExports.map: the desktop, static and wasm artifacts and the test registrations before changing the exported surface.

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.

Tests and validation
C API gates and CI · Test architecture