C API internals
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).
| Artifact | When | How the surface is controlled |
|---|---|---|
Shared library libcna_c_api | Every non-Emscripten build with CNA_BUILD_C_API=ON | CXX_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::CApiStatic | Option CNA_C_API_BUILD_STATIC, Linux and macOS hosts with Python 3; also under Emscripten | generate_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_wasm | Emscripten (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_alloc | OUT_OF_MEMORY |
std::overflow_error, std::range_error | OVERFLOW |
std::out_of_range, std::invalid_argument | INVALID_ARGUMENT |
other std::logic_error | INVALID_STATE (deliberately not INTERNAL) |
std::ios_base::failure, std::filesystem::filesystem_error, System::IO::IOException, ContentLoadException | IO |
StorageDeviceNotConnectedException, InstancePlayLimitException, gamer-privilege / guide-visible, sensor failure, network-session join failure, network not available | INVALID_STATE (sensor and join failures also record their error id in the thread's diagnostic) |
NoAudioHardwareException, NoMicrophoneConnectedException, gamer services not available, game update required | NOT_SUPPORTED |
other NetworkException, CNA::Platform::PlatformException | PLATFORM |
CNA::CNAException, System::InvalidOperationException, DeviceLostException, DeviceNotResetException | INVALID_STATE |
System::ArgumentException, System::InvalidCastException | INVALID_ARGUMENT |
System::NotImplementedException, System::NotSupportedException, NoSuitableGraphicsDeviceException | NOT_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):
| Shape | Example | Lifetime rule |
|---|---|---|
| Owned resource with parent token | SoundEffectResource, Texture2DResource | Holds the native object by shared_ptr plus parentGame; destroyed by its own route; counted against cna_game_destroy. |
| Owned child with parent reference | SoundEffectInstanceResource | Holds a shared_ptr to the parent resource and increments the parent's child count; the parent refuses destruction until it is zero. |
| Borrowed game device | BorrowedGraphicsDevice | A 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 device | OwnedGraphicsDevice | A 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 borrow | cna_game_get_content_manager_ext | One 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 borrow | BorrowContentManagerForCallback | A 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 references | Texture2DResource active batch/font/effect/model/scope counts | cna_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
- Resolve the game through
GetCallableGame(kind, thread, not inside a callback). - Refuse with
INVALID_STATEwhile 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-wideRuntimeState; graphics resources are counted only when their owner token is a game (AddOwnedGraphicsResourceFor). - Turn the foreign-thread call queue off (
EndForeignThreadCalls), failing every call still waiting withINVALID_STATE. CGame::Shutdown, inside a callback scope: theexitingcallback (once), thenDispose(), so subscribers still observe the game's ownDisposedevent, the disposal of its components (Disposed) and of the graphics device manager (DisposedandDeviceDisposing); 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 canonicalGraphicsDevice:Game::Dispose(true)never does, and the device raisesDisposingonly from its destructor once the game object itself goes away, after the adapter state below has been reset. The source comment after theShutdowncall that says otherwise is inaccurate.- 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
Gamecaches a raw pointer to the graphics device service, so releasing a manager's handle cannot free the manager while the game lives. - Release the game handle, clear
hasActiveGameand 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_Handleas 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_THREADand 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_Resultplus the current thread's last diagnostic, read immediately. The store isthread_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 handleBUFFER_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) andstruct_version == 1; a smaller prefix is refused withINVALID_ARGUMENT, a larger future struct passes the check. Validators must check both before reading a new field. - Strings.
CNA_StringViewis a borrowed{data, byte_length}span valid for one call, not a NUL-terminated or long-lived string.ValidateStringViewacceptsdata == NULLonly with length 0, rejects overlong encodings, surrogates and code points above U+10FFFF (ENCODING), and optionally rejects embedded NUL;CopyStringViewvalidates before copying. The rules are cross-checked byUtf8OracleTest.cpp(CApi_Utf8Oracle) and a libFuzzer source compiled but not registered as a test. - Booleans. Only
CNA_FALSE(0) andCNA_TRUE(1) are valid;ValidateCanonicalBoolnames the offending parameter. Since ABI 0.3.0 every route taking one refuses other bytes; the generatedCApi_BoolContractSmoketest and theCApiBoolContractCurrentgate keep that true. - Buffers.
CheckedElementByteCountmultiplies element count by element size with overflow checks against bothuint64_tandsize_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
- 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).
- Declare it in the owning public header with its ownership, thread and result contract written out.
- Implement it in the owning
CnaCApi*.cppfamily: output cleared first,CallWithExceptionBarrier, handle kind and thread through the registry, validation as above, child counts if it creates a child. - Apply the ABI version policy in
ABI_VERSIONING.md: while 0.x, an incompatible change needs a minor increment, release notes and a regeneratedabi_baseline.json(the CMake package version is read out ofabi.h). - 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, andCApiRouteTestCoverage, whose budget of uncovered routes is 0, so the new route must be named by a test or example. - Add pure-C tests under
tests/pure_cplus focused boundary or handle tests, then run the installed-consumer check. - 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
| Evidence | What it checks | Where it runs |
|---|---|---|
AbiHeaderC.c, AbiHeaderCpp.cpp | Every public header compiles as C17 and as C++ under strict warnings (object libraries, not CTest entries) | C API builds |
CApi_HandleRegistry, CApi_BoundaryDetail, CApi_Utf8Oracle | Registry 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 C | C API builds |
CApi_InstalledConsumer | Install the CNACApi component, build hello_cna shared and static from outside the tree, run both | Local 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.cmake | Recorded layouts and constants, export sets, route-test coverage, release verdict | Ordinary 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,
.cnbloader callbacks, streaming audio, storage). Read the route's header and implementation rather than extrapolating from the SoundEffect example. cna_graphics_device_destroyfor a caller-created device disposes and releases it without consulting a child count in its route body;graphics_device.hdocuments that the device's resources are released with it (GraphicsDevice::Disposedisposes 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
abi.h,core.handruntime.h: wire types, error queries and the game ownership statements before any implementation.CnaCApiDetail.hppandCnaCApiDetail.cpp: exception arms,LastError, string/bool/buffer checks, handle encoding, generation and thread enforcement.CnaCApiRuntime.cppandCnaCApiRuntimeDetail.hpp:CGame, callback scopes and latching, borrowed devices, the foreign-thread hooks, the child counters and the destroy sequence.CnaCApiAudio.cppwithAudioSmoke.candAudioSoundEffectSmoke.c: one owned parent/child family against its C-only tests.CnaCApiContent.cpp(borrowed and callback-scoped content managers) andCnaCApiDisplay.cpp(caller-created devices): the two ownership shapes that differ most from the audio example.c-api CMakeLists.txtandCnaCApiExports.map: the desktop, static and wasm artifacts and the test registrations before changing the exported surface.
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- C API evidence, coverage inventory and release gate — How far the evidence for CNA's C ABI 0.46.0 reaches, what each release-gate criterion really checks, what the gate reports for the 631 public C++ declarations still planned for C mapping, and how the coverage inventory classifies modules.
- Native C API contract: admission, buffers, retention and route families — What CNA's C ABI 0.46.0 version checks admit, the exact count-then-copy protocol, callback, registration and thread rules, resources that retain others, caller-created devices and all 60 headers by family.
Known issues in this area
Current defects, gaps and limitations at this snapshot that touch this subject.
- CNA-BUG-056: docs/c-api/FEATURE_MATRIX.md and docs/c-api/README.md still present the C ABI as version 0.1.0 while abi.h declares 0.46.0 — The release gate now reads its ABI label from abi.h, but the C API feature matrix is titled '0.1 Feature Matrix' with an ABI row of 'Experimental version 0.1.0', and the C API README describes the library as 0.1.0.
- CNA-BUG-067: Math argument failures throw a mix of Sharp Runtime and std:: exception types that differs between near-identical functions — BoundingBox throws Sharp Runtime ArgumentException types as XNA does, but BoundingSphere::CreateFromPoints, BoundingFrustum::GetCorners and the perspective builders throw std::invalid_argument or std::out_of_range.
- CNA-BUG-212: cna_graphics_resource_get_graphics_device returns CNA_RESULT_INVALID_HANDLE for every resource created on a caller-created GraphicsDevice — A resource made on a device from cna_graphics_device_create carries that device's handle as its owner token, and the route passes it to the game-only BorrowGameGraphicsDevice, so asking such a valid resource for its devi
- CNA-BUG-213: audio.h and AUDIO.md call cna_dynamic_sound_effect_instance_submit_buffer safe from a producer thread, but every call off the creation thread returns CNA_RESULT_THREAD — The submission routes resolve the instance through HandleRegistry::Get, which refuses any thread other than the handle's creation thread, so the documented canonical producer-thread usage fails; AUDIO.md's own threading
- CNA-BUG-214: docs/c-api/HANDLES.md documents CNA_RESULT_SHUTTING_DOWN for a closing or closed runtime, but no C API route ever returns it — abi.h defines CNA_RESULT_SHUTTING_DOWN and HANDLES.md maps a closing runtime to it, but no route returns it and the registry has no shutdown state, so a stale handle answers CNA_RESULT_INVALID_HANDLE.
- CNA-BUG-218: content_readers.h documentation names cna_get_last_error_message, which does not exist — The Doxygen of cna_object_dictionary_ext_copy_value tells callers to read 'the message cna_get_last_error_message returns', a function declared nowhere; the C API's error accessors are the cna_error_* functions in core.h
- CNA-BUG-220: cna_game_destroy's comment says Shutdown has disposed the canonical graphics device; it has not — A comment in cna_game_destroy claims CGame::Shutdown already disposed the game's canonical GraphicsDevice so C subscribers saw its Disposing event, but Game::Dispose(true) never disposes the Game-owned device.
- CNA-VGAP-053: No CI workflow builds the C API library or runs its tests — No workflow at this snapshot configures CNA_BUILD_C_API=ON; the C API workflows only run generators, inventories, a release gate and a header compile matrix, so the library and its tests are verified only in local builds
- CNA-VGAP-054: Destroying a caller-created GraphicsDevice while resources on it are still live is allowed by cna_graphics_device_destroy but untested — graphics_device.h says resources on a caller-created device are released with it and the destroy route checks no child count, but every C test destroys the resources first, so what later calls on those resource handles d
Related pages
The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.
- Internals
- C# binding internals · Java binding internals (archived) · Python binding internals (archived) · Ownership and shutdown
- Maintainer workflow
- I need to update the C API · Thread and callback map
- Tests and validation
- C API gates and CI · Test architecture
- Reference
- Public header index · Test target index