I need to update the C API
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 from modules/c-api, tools/c-api, docs/c-api, the c-api-* workflows and CNA's plan records; nothing was configured, built or run, no workflow builds the C library, and the plan records of a C API build refer to an earlier commit.
Use this recipe when a change touches modules/c-api, a public C++ header the C layer adapts, or anything the C ABI records about itself: its version, layout baseline, coverage inventory or release gate. The C layer is a second ownership model over the canonical C++ surface and has more mechanical gates than any other module; most read files rather than a built library, so they register and run in an ordinary tests build. Everything here was read from the TARGET tree at 009d40f5; nothing was configured, built or run for this page.
What is and is not established. No workflow at TARGET builds libcna_c_api; the five c-api-*.yml workflows check headers, JSON and Doxygen output only. CNA's own plan record (plans/plan_capi_smoke_stability.md) reports a C API build and a ctest -R '^CApi' run on an earlier next commit (111 tests, 108 passing, the three failures being generator gates); that is CNA's measurement of another commit, not evidence for 009d40f5. A C++ change can therefore break the C layer without any automatic signal, and the C API's history contains exactly that.
Find the owner
The C layer lives in one module and is meant to change in the same commit as the C++ it adapts (docs/c-api/README.md). Its pieces and their checks:
| Piece | Where | Checked by |
|---|---|---|
61 public headers (C99 floor), aggregated by cna.h | include/CNA/C | CApi_HeaderAudit (no std::, class, template, namespace, throw; only stdint.h, stddef.h, stdbool.h and CNA/C/*.h may be included), CApiCompatibilityMatrix, CApiHeaderCompatibility |
Implementation: 59 translation units plus private *Detail.hpp | src, listed explicitly in modules/c-api/CMakeLists.txt (no glob) | The C API build itself (-Wall -Wextra -Werror) and the pure-C tests |
| Route tests | tests/pure_c (84 *Smoke.c programs plus AbiHeaderC.c), tests/cpp (registry, exception barrier, UTF-8 and LUT oracles), tests/fuzz | One add_test per program, under CNA_BUILD_TESTS |
| Recorded layout and exports | abi_baseline.json: 221 struct layouts, 350 scalar types, 1,558 integer constants, 4,055 exports | CApiAbiHeaderBaseline (headers only), CApiAbiBaseline (built library), the c-api-abi-baseline workflow |
| Coverage inventory and release gate | coverage_mappings.json (693 rules), the generated summary, release_gate.json, RELEASE_GATE.md | CApiCoverageMatrix, CApiLimitations, CApiReleaseGate and their workflows |
| Contract prose | docs/c-api | CApiDocExportCounts holds the export counts in four of the documents |
First decide whether the change belongs in the ABI at all. The inventory records 509 public C++ symbols as having no C form (templates over types C cannot name, protected hooks, the platform substrate, renderer implementations behind IGraphicsRenderer). Touching such a symbol needs no route but must leave the inventory consistent. The scope rules are in generate_coverage_inventory.py: MODULE_SCOPE classifies every module, so a new module stops the gate instead of inheriting a default.
Read first
abi.h:CNA_ABI_VERSION_MAJOR/MINOR/PATCH(0, 29, 0),CNA_ABI_VERSION_ENCODE,CNA_Resultcodes 0–14,CNA_Handle.docs/c-api/ABI_VERSIONING.md: the0.xrule, the four evolution paths, the recorded-baseline section.OWNERSHIP.md,HANDLES.mdandCALLBACKS_AND_THREADING.md, then C API internals for how the code implements them; this page does not repeat that trace.CnaCApiDetail.hpp(CallWithExceptionBarrier,HandleRegistry,ObjectKind, validators), then the family file that owns a neighbouring route and its pure-C smoke test.
Contracts a route must keep
- Handles are 64-bit: a 1-based slot in the low 32 bits, a generation in the high 32.
HandleRegistry::Getrefuses a stale generation or wrongObjectKind(CNA_RESULT_INVALID_HANDLE) and a foreign creation thread (CNA_RESULT_THREAD);Releaseand the user-tag accessors check the thread too. There is one process-wide registry. The kind is the only type guard, so resolving a handle under the wrong C++ type is undefined behaviour, not an error. - The exception barrier is positional. Catch arms are ordered most-derived first, so a new or re-parented native exception can land in a different arm;
CApi_BoundaryDetailpins representative mappings only. - A refused creation leaves the output invalid: clear every
out_handle first, validate second. - Structures are versioned prefixes (
struct_size >= sizeof,struct_version == 1); fields are appended, never reordered. EveryCNA_Boolroute refuses bytes other than 0 and 1, held by the generatedCApi_BoolContractSmoke. - Ownership is written into the header. OWNERSHIP.md names six categories (owned, borrowed, retained, transferred, callback context, caller buffer) and calls a declaration incomplete until its Doxygen names one. No test enforcing this was found; it is a review obligation.
- Owned children block their parent:
cna_game_destroyanswersCNA_RESULT_INVALID_STATEwhile an owned graphics resource, content manager, audio resource or game component lives. Event registrations are the deliberate exception. A new owned child type must join the relevant counter, not only the registry. - Two language levels. Headers are held to C99 (23 declared matrix cells across 7 toolchains); CNA's C targets compile as C17 with extensions off and warnings as errors.
ABI version discipline
The ABI version is independent of the product version string and of Sharp Runtime's; docs/releasing.md says it “moves when the ABI changes, independently of a product release”. It is defined once, by three macros in abi.h, encoded major<<16 | minor<<8 | patch (0.29.0 is 0x00001D00). Everything else derives from or is held against them:
| Consumer of the version | How it stays in step |
|---|---|
CMake package CNAConfigVersion.cmake | The module CMake file regex-reads the macros and writes the version file with SameMajorVersion; the package version is the ABI version. |
abi_baseline.json | generate_abi_baseline.py measures the macros from the headers and, with --library, what cna_get_abi_version() reports. |
AbiHeaderC.c | Deliberately carries no version literal: it checks only the encoding. A literal had gone two bumps stale (0.27.0 against 0.29.0) unnoticed and broke the build when the C API was next built. |
| Prose | The identity paragraph and history in ABI_VERSIONING.md; export counts are held by CApiDocExportCounts. |
ELF version node CNA_C_API_0.1 | Intentionally not the ABI version and never bumped with it; it would change only with a major break (CnaCApiExports.map). |
When the version moves
While the ABI is 0.x, an incompatible change needs a minor increment, release notes and a regenerated baseline; from 1.x, only additive changes within a major, and any removal or meaning change needs a new major. CNA also increments the minor for additive generations (0.23.0 added one route), “because a consumer needs a number to require”. ABI 1.0 is a separate decision the release gate says it does not make.
| Change | Class and version | Recorded at TARGET |
|---|---|---|
| New route, struct, appended field, constant or handle kind | Additive; minor by project practice | 0.23.0 (cna_decal_pass_is_supported), 0.26.0 (one CNA_RENDERER_FEATURE_* identity) |
| Route, struct or constant removed or renumbered | Incompatible; minor while 0.x, with notes and a regenerated baseline | 0.29.0 removed cna_sprite_batch_draw_mesh_ext and CNA_SpriteMeshEXT (4,056 → 4,055 exports, 222 → 221 layouts); 0.28.0 removed the constants of the curated-out renderer identities and moved CNA_GRAPHICS_RENDERER_MAXIMUM 51 → 46 |
| Meaning of an existing route, ownership rule, error rule or callback rule changes | Incompatible; minor while 0.x | 0.3.0 (every CNA_Bool route refuses non-canonical bytes); 0.9.0 (seven documented contract changes, four of which had landed unversioned under 0.8.0) |
| Renderer identity retired | Incompatible plus a permanent reservation | The next new identity takes 52, not MAXIMUM + 1; scripts/check_renderer_identities.py fails on reuse |
| C++ behaviour changes, every C signature unchanged | Depends on whether a documented C contract moves | Practice is mixed: 0.9.0 versioned such changes, while the plan record of thirteen stale smoke-test expectations (defaults, refusal codes, text formats) after the library stopped compiling corrected two header comments and the tests with no bump. Decide explicitly and say in review whether the old promise was part of the header |
How a version is bumped
Read from the tools and workflows; not executed. No script bumps the version: each step is a manual edit that a gate then holds.
- Edit the macro in
abi.h; repeat the literal nowhere that is not gated. - With the library built:
python3 tools/c-api/generate_abi_baseline.py --write --library <build>/modules/c-api/libcna_c_api.so, and review theabi_baseline.jsondiff. Additions are permitted; a moved field, changed constant or vanished export is named an ABI break. - Write the entry at the top of
ABI_VERSIONING.mdand keep the export counts in the four gated documents equal to the baseline (check_doc_export_counts.py --check). python3 tools/c-api/check_release_gate.py --writeregeneratesRELEASE_GATE.md, whose “Release” line names the ABI version.- Add a
CHANGELOG.mdentry under[Unreleased](the 0.29.0 entry names the removed route, the export and layout deltas and the deciding plan row). - Leave the installed-consumer example's
find_package(CNA 0.1 CONFIG)alone: it requests the version it was written against on purpose. Bindings are updated separately (below).
The coverage inventory and the release gate
generate_coverage_inventory.py parses modules/*/include/{Microsoft,CNA}/**/*.hpp with Doxygen (Internal and Detail path segments excluded) and maps every declaration through the rules. Read the numbers from the generated summary: at TARGET, 556 in-scope headers and 9,355 symbols, of which 8,363 implemented, 15 partial, 468 planned and 509 not applicable, with 425 headers deliberately out of scope (content pipeline, phone, platform, renderers and internal paths, each with a recorded owner decision). Planned rows by module: content 188, graphics 116, graphics-ext 133, input 10, math 10, runtime 11.
The release gate reads Not ready: nine criteria are recorded as met, and one, “No public C++ symbol is unaccounted for”, is recorded as not met because of those 468 rows. Running the gate against this snapshot measures two unmet, not one: the coverage inventory stops at its scope check (“The runtime C API scope model does not describe this tree”), which also fails the limitations criterion recorded as met, so check_release_gate.py --check fails. That is the gate working: check_release_gate.py fails when a criterion recorded as met stops being met and when one recorded as blocked has quietly become met, so closing the last planned row also forces a change to release_gate.json. It governs publishing an experimental release only.
| Situation | What fails | What to do |
|---|---|---|
| A new public C++ symbol in a module the C ABI links | CApiCoverageMatrix, then the release gate | Bind it and extend or add a rule (status implemented, a task, a tests text), or record an owner-approved not-applicable/partial. Nothing is reclassified to turn a gate green. |
| Editing a header a rule matches | The rule's approved_symbols disagree with what it matches | Rules are pinned to stable CPP-… IDs; widening one needs --approve-rule-symbols --rule ID --grow-approved-symbols after a human checks the routes cover the new symbols. |
| A new module | validate_module_scope | Classify it in MODULE_SCOPE with an owner decision. |
| Prose about limits is stale | CApiLimitations | Edit limitations.json or the mapping and run generate_limitations.py --write; never hand-edit generated files. |
The inventory tools need Doxygen and the sibling sharp-runtime checkout (the workflows install Doxygen and run scripts/ci/clone_siblings.sh); the ABI-header, route-coverage, bool-contract and compatibility checks need only Python and a C compiler. Two documentation gaps: the per-task open-row counts in plans/plan_binding.md (320 for CBIND-127) are not kept equal to the generated 468, and release_gate.json's note still speaks of 654 content-pipeline declarations although the summary now scopes that module out. Trust the generated summary.
Adding a route (procedure, not executed)
The shortest path that satisfies every gate for a new fallible route in an existing family, derived from existing routes and tests. A new handle kind grows steps 3–4; a new module grows the scope row above.
- Write the contract first. Owned, borrowed, retained, transferred or copied; which thread; which result codes; whether a child count is involved. Put it in the Doxygen of a declaration that is C99,
CNA_C_API-marked,cna_-prefixed and fixed-width only (nolong,size_t,boolor bit-fields). - Classify the ABI change with the table above.
- Implement it in the owning
CnaCApi*.cppfamily: clear outputs,CallWithExceptionBarrier, kind and thread through the registry, shared validators. A new source file must be added to the explicitadd_library(cna_c_api ...)list. - If it owns a resource, add an
ObjectKind, a parent token and, for a game child, the matching owned counter socna_game_destroyrefuses while it lives. The ELF map exportscna_*by pattern and the wasm export list is generated from the headers (generate_wasm_exports.py); neither is edited. - Write the pure-C test with
<CNA/C/cna.h>andCnaTestReport.h(CNA_TEST_FAIL(n)names the failing stage). Cover success, a refused argument (output staysCNA_INVALID_HANDLE), a stale handle, the wrong thread, and child-before-parent destruction. Register it beside its neighbours:add_executable,C_STANDARD 17,C_EXTENSIONS OFF, linkcna_c_api,cna_c_api_enable_strict_warnings,add_test(NAME CApi_…), andSDL_VIDEODRIVER=dummywhere a window opens. - Route-test coverage.
CApiRouteTestCoveragehas a budget of 0 uncovered routes and counts a route as covered when any file undertests/orexamples/names it. If the route takes aCNA_Bool, rungenerate_bool_contract_test.py --writeorCApiBoolContractCurrentfails. - Record layout and exports (
generate_abi_baseline.py --write --library ..., additions only expected), thencheck_declared_exports.py --library ..., which compares declared and exported names both ways. - Update the inventory: mapping rule, then
generate_coverage_inventory.py --writeandgenerate_limitations.py --write; a route that closes a planned row should lower the count. - Bump the version and update
ABI_VERSIONING.md,CHANGELOG.mdand the family document (AUDIO.md,GRAPHICS_DEVICE.md, …). - Run the gates below, and the installed-consumer check if headers, exports or packaging changed.
Tests and gates
| Gate | Needs | Catches |
|---|---|---|
CApiAbiHeaderBaseline, CApiCompatibilityMatrix, CApiHeaderCompatibility, CApiBoolContractCurrent, CApiRouteTestCoverage, CApiDocExportCounts | Ordinary tests build with Python 3; no C library | Moved fields or constants, headers not self-contained at C99, a stale generated bool test, routes with no caller, stale export counts |
CApiCoverageMatrix, CApiCoverageScopeModel, CApiLimitations, CApiReleaseGate | Python 3; the inventory ones also Doxygen and sharp-runtime | An unmapped public symbol, a stale generated summary, a criterion that disagrees with its measurement |
CApiAbiBaseline, CApiDeclaredExports, CApi_Exports | The built library, ELF only | A vanished or unexpected export, a declared route the library does not export, a wrong reported version |
84 CApi_*Smoke programs, CApi_TeardownLifetime_* (seven modes), CApi_StressSmoke, CApi_HandleRegistry, CApi_BoundaryDetail | CNA_BUILD_C_API=ON with CNA_ENABLE_NET=ON | Route behaviour, refusals, handle recycling, process-exit teardown |
CApi_InstalledConsumer | C API build, Linux/ELF, serial, 900 s timeout | An install unusable from outside the tree, shared and static, without an environment variable |
CApi_WasmLinkContract, CApi_WasmModuleSmoke, CApi_WasmBrowserProbe | Emscripten with Node (Playwright for the probe) | A route missing from the browser module |
CNA_BUILD_C_API=ON requires CNA_ENABLE_NET=ON (a configure-time refusal otherwise) and is OFF in every shipped preset. Pair a lifetime change with a sanitizer tree: the process-exit crash in this module was found and proved with AddressSanitizer, and CApiAbiBaseline forwards CNA_SANITIZE so a sanitized tree can still run it. See debug shutdown and lifetime.
What a C++ change costs downstream
| C++ change | Inside the tree |
|---|---|
| Rename, re-type or remove a wrapped public symbol | The C layer stops compiling. The effect collections' operator[](int) returned T* while CnaCApiEffects.cpp still took its address, unnoticed until the library was built again (CHANGELOG, “-DCNA_BUILD_C_API=ON builds again”). |
| Add or remove a renderer identity | Two static_asserts in CnaCApiCoreExt.cpp compare the 25-row identity table with the C++ count and CNA_GRAPHICS_RENDERER_MAXIMUM with the highest published value. The build failure at the first tag was exactly this assertion; removing a constant is also an incompatible ABI step. |
| A virtual stops being pure or a default changes | An adapter override written for the old contract may now be wrong: Effect::Clone() stopped being pure virtual and the C adapter's override silently dropped a compiled effect's runtime, so the override was deleted. |
| A generic template the wrapper reached changes semantics | A route regressed while both test suites were individually right: the raw vertex upload route, repaired by two new public forwarders on DynamicVertexBuffer. |
| A new or re-parented native exception | May move failures between barrier arms, giving a different CNA_Result. |
| A profile or limit tightens (Reach enforcement, depth-stencil refusals) | Smoke tests written against the old default fail: CNA's plan classified thirteen stale expectations after the library stopped compiling. |
The external bindings (cna-cs, cna-java, cna-ts, cna-python, cna-rust, cna-swift, cna-go, cna-ruby) are separate repositories on their own cadence. Each targets ABI 0.21.x while TARGET exports 0.29.0 after two incompatible steps; five refuse a 0.29.0 library by exact-version rules and none is qualified against it (per-binding table on the bindings boundary). For a C API change this means: (a) a 0.x minor is not backwards compatible although find_package and CONSUMING.md (“every minor within a major is additive”) suggest otherwise, so a binding that admits a range trusts a rule, not a guarantee; (b) an additive route is invisible to a binding until its own manifest adds it (the C# binding compiles a matrix, the Python loader refuses a missing export by name); (c) CNA cannot run the bindings' suites, so the evidence a maintainer can hand over is the regenerated baseline, the header diff and the release notes. The migration shapes are on the C#, Java and Python pages, and the propagation checklist is in C API and bindings architecture.
Traps
- Green ordinary build, broken C layer. Build
cna_c_apiin aCNA_BUILD_C_API=ONtree before claiming a wrapped-header change is unaffected, or say plainly that you did not. - A defined result code nothing returns.
CNA_RESULT_SHUTTING_DOWN(13) is declared, mapped to an error category and documented in HANDLES.md (“runtime is closing or closed”), yet searchingmodules/c-api/srcfinds no route returning it; a stale handle answersINVALID_HANDLE. Do not document or test behaviour the implementation lacks. - Static destruction. The registry is a function-local static in
CnaCApiRuntime.cppthat owns games. CNA made the platform bookkeeping it depends on immortal because reverse-order destruction otherwise read freed memory at exit; a new process-lifetime static that aGamedestructor touches must follow that idiom. - A finalizer is not a destroyer. A garbage-collected wrapper releasing children on another thread gets
CNA_RESULT_THREADand loses the handle. - Counting a mention as a test.
CApiRouteTestCoveragepasses on a name appearing in any test source; assert behaviour. - Caller-created devices.
cna_graphics_device_destroydisposes without a child-count check in its route body, and that is the documented contract:graphics_device.hsays resources on a caller-created device belong to it and are released with it (GraphicsDevice::Disposedisposes its resource list), so destroying the device first is permitted. The pure-C tests still destroy resources first, and what a child handle answers after its device is gone is not pinned by a test. Treat that part as an open rule.
Review checklist
- Header: C99 only, ownership category, thread rule and result codes written down.
- Implementation: outputs cleared first, barrier, kind and thread, child counter, callback re-entrancy rules kept.
- ABI: change class named;
abi.h, baseline,ABI_VERSIONING.md,CHANGELOG.md, release-gate record and prose export counts agree. - Tests: a pure-C test that fails when the route is wrong; the destroy-order and wrong-thread cases; which gates ran, which need a built library, which were not run.
- Inventory: no unreviewed planned row, no rule widened without the approval flag, limitations regenerated.
- Bindings: each binding's generation, the fact that none is qualified against this ABI, and any removal or renumbering called out.
Related: Ownership and lifetime master map, Thread and callback map, Blast radius and readiness, Make a release and Change public XNA behaviour.
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.29.0 reaches, what each release-gate criterion really checks, why the gate measures two unmet criteria at 009d40f5, and how the coverage inventory classifies modules.
- Native C API contract: admission, buffers, retention and route families — What CNA's C ABI 0.29.0 version checks admit, the exact count-then-copy protocol, callback and registration lifetimes, resources that retain others, caller-created devices and all 61 headers by family.
Known issues in this area
Current defects, gaps and limitations at this snapshot that touch this subject.
- CNA-BUG-055: Stale whole-registry renderer counts survive outside check_renderer_identities.py's list: the C API's CORE.md and FEATURE_MATRIX.md say 50, core_ext.h says 46, ModuleProbes.cmake says 42, and CHANGELOG says 49 for a 50-identity release — CNA has 25 public renderer identities, yet the C API's backend-classification contract, a public C header's Doxygen, a CMake test header and the alpha.1 release notes still state obsolete totals that no count gate reads.
- 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.29.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-058: LIMITATIONS.md omits the 468 planned C API symbols and RELEASE_GATE.md still attributes 654 declarations to CBIND-117 — The generated C API limitations document partitions 8,887 of the 9,355 declarations it counts and never mentions the 468 planned ones, and the release-gate note still says the Content Pipeline task owns 654 declarations
- CNA-BUG-062: MODULE_SCOPE in generate_coverage_inventory.py leaves design, diagnostics and inspector unclassified, so every C API inventory gate stops — The C API coverage generator refuses to run because three public modules are missing from MODULE_SCOPE, so the coverage, limitations, scope-model and release-gate checks fail at this snapshot and the committed coverage n
- CNA-GAP-062: The C ABI has no route for 468 public C++ declarations that the coverage inventory records as planned — COVERAGE.md records 468 in-scope public C++ declarations without a C mapping (320 runtime surface added after the binding campaign, 134 CNB Model-v2 surface, 14 smaller seams), which is why the C ABI release gate reads N
- 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.
- User guide
- Experimental C API: ABI version history · Experimental C API: gates and CI · Experimental C API: bindings boundary
- Architecture
- C API and bindings architecture
- Maintainer workflow
- Ownership and lifetime master map · Blast radius and readiness · Make a release
- Tests and validation
- Test architecture
- Reference
- Test target index