C API internals

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

✓

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 c1c316b9; unless a sentence says otherwise, nothing here was built or executed. Read at c1c316b9 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 c1c316b9; nothing was built or executed for this page. The option CNA_BUILD_C_API defaults to OFF, no CI workflow builds the library (CNA’s own local runs do, on Linux and, in its Apple campaign, on a physical Mac mini M4), and CNA's own release gate for ABI 0.46.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 60 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.46.0: CNA_ABI_VERSION_MAJOR 0, _MINOR 46, _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 (UNIX AND NOT APPLE AND NOT EMSCRIPTEN) the link adds --exclude-libs,ALL and the version script CnaCApiExports.map; on Apple it passes ld64 -exported_symbols_list with CnaCApiExports.darwin.txt (_cna_*), checked by CApi_Exports through nm -gU. Either way cna_* is global and everything else local. Run path: INSTALL_RPATH "$ORIGIN" on ELF and @loader_path on macOS, where the dylib also gets the install name @rpath/libcna_c_api.dylib and Mach-O compatibility/current versions derived from the ABI version (MAJOR.MINOR.0 / MAJOR.MINOR.PATCH).
Static archive CNA::CApiStaticOption CNA_C_API_BUILD_STATIC, Linux and macOS hosts with Python 3; also under Emscriptengenerate_static_archive.py partially links the closure into one object and localizes every non-cna_* symbol (GNU ld -r and objcopy on ELF; ld64’s ld -r -all_load and nmedit -s on macOS, where exactly the _cna_* names must survive); consumers get CNA_C_API_STATIC, which empties the export macro. Under Emscripten, where the library's objects are WebAssembly that the host partial link cannot read, the script instead merges the archive from the closure of the cna_c_api_wasm link.
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 a one-frame route from requestAnimationFrame, either cna_game_run_one_frame or the host-driven cna_game_run_frame_ext (0.38.0), which also begins and ends the run. With CNA_ENABLE_EMSCRIPTEN_THREADS the module is linked with a fixed 64 MB initial memory, so the game thread can be a worker. 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 that runs the body through CallHereWithExceptionBarrier, whose catch arms are ordered from most derived to most general, so C++ exceptions never cross the ABI (what it does with a THREAD refusal is under calls from other threads). 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::CNAException, System::InvalidOperationException, DeviceLostException, DeviceNotResetExceptionINVALID_STATE
System::ArgumentException, System::InvalidCastExceptionINVALID_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. Before any arm runs, NoteTranslatedException notes the exception's canonical .NET type name (for example System.ArgumentOutOfRangeException) and, for an argument exception, its parameter name, and Fail attaches them to the record; a failure the C layer raises by itself carries neither. A successful call does not clear the record; the seven cna_error_* queries in core.h never overwrite it (three read the result, category and message, and four, added in 0.37.0, copy the exception type name and parameter name). 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 139 kinds, numbered from 1 to 181 with gaps that are not reused, 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 and GetFromAnyThread (below) skip 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.

Calls from other threads

The creating-thread rule has three narrow exceptions at TARGET, and the first is built into the barrier itself. When Get<T> refuses a handle only because another thread created it, it calls NoteForeignThreadRefusal() before answering THREAD; any other THREAD answer, such as a route that must itself run on the game thread, is not noted. CallWithExceptionBarrier clears the note, runs the body through CallHereWithExceptionBarrier (the arms above) and, when the result is THREAD, the note was set and ShouldServeOnGameThread() holds (the queue is on and the caller is not the game thread), hands the same body to ServeOnGameThread: the call joins a queue, the caller blocks, and it receives the result and, for a failure, the error record the call left on the game thread (CnaCApiDetail.hpp, CnaCApiDetail.cpp). The queue is off by default; cna_game_set_foreign_thread_calls_ext (0.39.0), called on the game thread, turns it on and makes that thread the one that serves it. CGame::Update and CGame::Draw open their callback scope and then call RunForeignThreadCalls(), which runs the queued calls and keeps serving for up to 8 ms while more arrive; cna_game_run_foreign_thread_calls_ext (0.41.0) runs the same function on demand, for a game thread that waits inside a callback for a thread whose calls only it can run; and cna_game_destroy calls EndForeignThreadCalls(), which fails every waiting call with INVALID_STATE, before it shuts the game down. A game thread that waits for such a thread without draining the queue deadlocks.

The second exception is HandleRegistry::GetFromAnyThread, the same lookup and kind check without the thread check. cna_sound_effect_play and cna_sound_effect_play_with_settings resolve their effect through it (0.43.0, GetSoundEffectToPlay in CnaCApiAudio.cpp); every other sound-effect route, including cna_dynamic_sound_effect_instance_submit_buffer, keeps the creating-thread rule. The third is that standalone devices from cna_graphics_device_create may be created and destroyed on several threads at once: graphics_device.h states that CNA serialises building and tearing down their window, renderer and context.

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 callback scope, or before the run begins, and released when the outermost scope closes.
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_run_frame_ext, 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 under a CallbackScope, which raises the game's callbackDepth_. The scope is a depth, not a flag: a lifecycle step opens it around the consumer's callback and the base pass that walks the components, CNA opens it again around each component's C handler, and the Activated and Deactivated handlers registered with cna_game_subscribe run inside one too (0.44.0); nested scopes share one borrowed device, which the outermost scope releases when it closes. 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, except the unload_content cleanup, which still runs after the content manager has been unloaded, as measured on real XNA (LifecycleFailureOracle.cs). cna_game_run, _run_one_frame, _run_frame_ext and even cna_game_destroy return the latched result.

A lifecycle callback, a component's C handler and an Activated or Deactivated handler may borrow the graphics device with cna_game_get_graphics_device, and the borrowed handle is released when the outermost callback scope closes. Before the run begins the creating thread may borrow it too (0.42.0): that is the device XNA's ApplyChanges in a game's constructor creates, and the handle lasts until the run's first callback returns. Once the run has begun, the route answers INVALID_STATE outside a callback scope. The twelve cna_graphics_adapter_* routes also accept the active game's own handle on its thread (0.40.0), so a game can read the default adapter in its constructor. GetCallableGame refuses to re-enter run, run_one_frame, run_frame_ext, tick or destroy from inside a callback (INVALID_STATE), while request_exit, clear, set_window_title and set_frame_hooks_ext are allowed there. cna_game_run_frame_ext (0.38.0) is the shape of cna_game_run for a host that cannot block: its first call initializes the game and begins the run, every call runs one frame through C++ Game::RunFrameEXT, and the call that finds the game has exited ends the run with exiting and end_run and reports out_running false; cna_game_run_one_frame never begins or ends a run. 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. Turn the foreign-thread call queue off (EndForeignThreadCalls), failing every call still waiting with INVALID_STATE.
  4. CGame::Shutdown, inside a callback scope: the exiting callback (once), 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. unload_content, if content was loaded, runs after the content manager has been unloaded: during that disposal when a graphics device manager is registered, otherwise right after it, which is the order measured on XNA, and even after a latched callback failure. 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.
  5. 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.
  6. 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# binding page and the archived 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, or, with foreign-thread calls on, the finalizer blocks until the game thread runs the destroy.
  • 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 maintained C# binding and wasm callers. The eight archived bindings stay at their final revisions and are not updated, so a change also widens the distance to them.

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 the maintained C# binding, at a recorded revision; the archived bindings are not updated.

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
93 pure-C programs (CApi_*Smoke, CApi_TeardownLifetime_*, CApi_AudioSmoke, CApi_RuntimeGameSmoke, CApi_OwnedGraphicsDeviceSmoke, CApi_HostDrivenRunSmoke, CApi_ForeignThreadCallsSmoke, CApi_ConcurrentDeviceCreationSmoke, CApi_RuntimeComponentDeviceBorrow …)Route behaviour, lifetime, threading 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 and macOS (where it also checks the installed dylib’s LC_RPATH @loader_path)
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 itself establish that the library builds at c1c316b9 (CNA’s Apple campaign records it built and every C API test passing on macOS for this branch), nor binary compatibility of any external binding. TARGET exports 0.46.0. The maintained C# binding admits exactly 0.46.0, after its maintainers reviewed the 0.44.0 to 0.46.0 difference (no prototype, layout or string change) and ran its ABI fixtures against a 0.46.0 macOS library; the archived bindings stopped at 0.21–0.23 (Common Lisp) and 0.35.0 (the other seven), and the steps from 0.30.0 to 0.35.0 were incompatible. See Bindings boundary and the C# and archived 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 scopes and latching, borrowed devices, the foreign-thread hooks, 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