I need to update the C API

CNA snapshot c1c316b9  ·  Development › Maintainer Handbook  ·  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 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 c1c316b9; 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 records report C API builds and ctest -R '^CApi' runs on other commits: the plan record plans/plan_capi_smoke_stability.md on an earlier next commit (111 tests, 108 passing, the three failures being generator gates), the earlier commit that set ABI 0.44.0 (ed885a8ae, an ancestor of this snapshot: ^CApi 119/119, C API gates 9/9) and, most recently, CNA’s Apple campaign on a physical Mac mini M4 (every C API test, 119/119 in each of the SOFTWARE and SDL_RENDERER trees, the installed-package consumer shared and static, the export and baseline gates read from the Mach-O library). Those are local measurements of other commits, not CI and not evidence produced for this page; the macOS ones share c1c316b9’s modules/c-api and tools/c-api sources but not every module the library links. 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:

PieceWhereChecked by
60 public headers (C99 floor), aggregated by cna.hinclude/CNA/CCApi_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.hppsrc, listed explicitly in modules/c-api/CMakeLists.txt (no glob)The C API build itself (-Wall -Wextra -Werror) and the pure-C tests
Route teststests/pure_c (90 *Smoke.c programs plus AbiHeaderC.c, RuntimeComponentDeviceBorrow.c and ServiceClient.c), tests/cpp (registry, exception barrier and UTF-8 oracle, plus the C++ header compile check), tests/fuzzOne add_test per program, under CNA_BUILD_TESTS
Recorded layout and exportsabi_baseline.json: 197 struct layouts, 295 scalar types, 1,466 integer constants, 3,210 exportsCApiAbiHeaderBaseline (headers only), CApiAbiBaseline (built library), the c-api-abi-baseline workflow
Coverage inventory and release gatecoverage_mappings.json (628 rules), the generated summary, release_gate.json, RELEASE_GATE.mdCApiCoverageMatrix, CApiLimitations, CApiReleaseGate and their workflows
Contract prosedocs/c-apiCApiDocExportCounts holds the export counts in four of the documents

First decide whether the change belongs in the ABI at all. The inventory records 443 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

  1. abi.h: CNA_ABI_VERSION_MAJOR/MINOR/PATCH (0, 44, 0), CNA_ABI_VERSION_ENCODE, CNA_Result codes 0–14, CNA_Handle.
  2. docs/c-api/ABI_VERSIONING.md: the 0.x rule, the four evolution paths, the recorded-baseline section.
  3. OWNERSHIP.md, HANDLES.md and CALLBACKS_AND_THREADING.md, then C API internals for how the code implements them; this page does not repeat that trace.
  4. 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::Get refuses a stale generation or wrong ObjectKind (CNA_RESULT_INVALID_HANDLE) and a foreign creation thread (CNA_RESULT_THREAD); Release and the user-tag accessors check the thread too; only GetFromAnyThread skips it, for the two sound-effect play routes (0.43.0). 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_BoundaryDetail pins representative mappings only.
  • Threading is part of the contract. A refusal that Get raises only because another thread created the handle is noted, and with cna_game_set_foreign_thread_calls_ext on (0.39.0, off by default) CallWithExceptionBarrier re-runs the whole route body on the game thread while the caller blocks; the game thread serves the queue at the start of each update and draw and in cna_game_run_foreign_thread_calls_ext (0.41.0). A new route that resolves its handles through the registry gets this for free; a route that must itself run on the game thread has to refuse with a THREAD that is not noted, as driving or destroying the game does. Allowing a route on any thread, or in a new phase (before the run, in an event handler), is a rule change that takes a minor.
  • 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. Every CNA_Bool route refuses bytes other than 0 and 1, held by the generated CApi_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_destroy answers CNA_RESULT_INVALID_STATE while 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.46.0, this snapshot, is 0x00002E00). Everything else derives from or is held against them:

Consumer of the versionHow it stays in step
CMake package CNAConfigVersion.cmakeThe module CMake file regex-reads the macros and writes the version file with SameMajorVersion; the package version is the ABI version.
abi_baseline.jsongenerate_abi_baseline.py measures the macros from the headers and, with --library, what cna_get_abi_version() reports.
AbiHeaderC.cDeliberately 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.
ProseThe identity paragraph and history in ABI_VERSIONING.md; export counts are held by CApiDocExportCounts.
ELF version node CNA_C_API_0.1Intentionally 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.36.0, 0.38.0, 0.39.0 and 0.41.0 each added one route) and for a rule that widens with every signature unchanged, “because a consumer needs a number to require”. ABI 1.0 is a separate decision the release gate says it does not make.

ChangeClass and versionRecorded at TARGET
New route, struct, appended field, constant or handle kindAdditive; minor by project practice0.36.0 (cna_packet_reader_copy_data_ext), 0.37.0 (four exception type and parameter-name queries), 0.38.0 (cna_game_run_frame_ext), 0.39.0 and 0.41.0 (the two foreign-thread call routes), 0.46.0 (the native-window-system constant CNA_NATIVE_WINDOW_SYSTEM_UIKIT)
Route, struct or constant removed or renumberedIncompatible; minor while 0.x, with notes and a regenerated baseline0.30.0 deleted a whole header, engine_layer.h, and with it 842 net exports and 23 layouts; the gamer-services line removed eleven avatar real-rendering routes and CNA_AvatarAppearanceEXT (its 0.33.0) and the routes cna_guide_set_is_visible and cna_guide_set_is_trial_mode (its 0.34.0), and 0.35.0 merged the two lines that had both used 0.33.0 under an unused number
Meaning of an existing route, ownership rule, error rule or callback rule changesIncompatible; minor while 0.x0.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); 0.32.0 (Guide and the gamer-services dispatcher answer by configured service availability, and a duplicate initialization answers INVALID_STATE)
A call that was refused is now accepted (a thread, phase or handle rule widens); every export, layout and constant unchangedRule-only; minor by project practice. It passes every link and baseline check, so only the release notes and the header text show it0.40.0 (the twelve cna_graphics_adapter_* routes accept the game handle), 0.42.0 (the device is lent before the run begins), 0.43.0 (sound-effect play on any thread), 0.44.0 (Activated and Deactivated handlers may borrow the device)
A renderer identity constant leaves the ABIIncompatible plus a permanent reservation of its valueRecorded step by step in ABI_VERSIONING.md; CNA_GRAPHICS_RENDERER_MAXIMUM is now 43 (FNA3D), the next new identity takes 52, not MAXIMUM + 1, and scripts/check_renderer_identities.py fails on reuse
C++ behaviour changes, every C signature unchangedDepends on whether a documented C contract movesPractice 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.

  1. Edit the macro in abi.h; repeat the literal nowhere that is not gated.
  2. With the library built: python3 tools/c-api/generate_abi_baseline.py --write --library <build>/modules/c-api/libcna_c_api.so, and review the abi_baseline.json diff. Additions are permitted; a moved field, changed constant or vanished export is named an ABI break.
  3. Write the entry at the top of ABI_VERSIONING.md and keep the export counts in the four gated documents equal to the baseline (check_doc_export_counts.py --check).
  4. python3 tools/c-api/check_release_gate.py --write regenerates RELEASE_GATE.md, whose “Release” line names the ABI version.
  5. Add a CHANGELOG.md entry under [Unreleased] (the entries that moved the ABI to 0.29.0, 0.31.0 and 0.33.0 name what was removed, the deltas and the deciding plan row). Practice is uneven: ABI_VERSIONING.md records every step from 0.30.0 to 0.46.0, but [Unreleased] has no entry for most of them (none for 0.46.0), so read the history in ABI_VERSIONING.md.
  6. 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, 469 in-scope headers and 8,142 symbols, of which 7,053 implemented, 15 partial, 631 planned and 443 not applicable, with 451 headers deliberately out of scope (content pipeline, design, diagnostics, inspector, phone, platform, renderers and internal paths, each with a recorded reason). Planned rows by module: graphics 275, content 206, graphics-ext 40, input 30, gamer-services 29, math 27, runtime 19, net 15, audio 10, devices 2, storage 2, media 1.

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 631 rows. MODULE_SCOPE at TARGET classifies every module that exists, design, diagnostics and inspector included, so the scope check the inventory runs first has an answer for each; the gate was not re-run for this page. The gate is built to fail in both directions: 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.

SituationWhat failsWhat to do
A new public C++ symbol in a module the C ABI linksCApiCoverageMatrix, then the release gateBind 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 matchesThe rule's approved_symbols disagree with what it matchesRules 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 modulevalidate_module_scopeClassify it in MODULE_SCOPE with an owner decision.
Prose about limits is staleCApiLimitationsEdit 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 631, 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.

  1. 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 (no long, size_t, bool or bit-fields).
  2. Classify the ABI change with the table above.
  3. Implement it in the owning CnaCApi*.cpp family: clear outputs, CallWithExceptionBarrier, kind and thread through the registry, shared validators. A new source file must be added to the explicit add_library(cna_c_api ...) list.
  4. If it owns a resource, add an ObjectKind, a parent token and, for a game child, the matching owned counter so cna_game_destroy refuses while it lives. The ELF map exports cna_* by pattern, as does the macOS exported-symbols list (_cna_*, CnaCApiExports.darwin.txt), and the wasm export list is generated from the headers (generate_wasm_exports.py); neither is edited.
  5. Write the pure-C test with <CNA/C/cna.h> and CnaTestReport.h (CNA_TEST_FAIL(n) names the failing stage). Cover success, a refused argument (output stays CNA_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, link cna_c_api, cna_c_api_enable_strict_warnings, add_test(NAME CApi_…), and SDL_VIDEODRIVER=dummy where a window opens.
  6. Route-test coverage. CApiRouteTestCoverage has a budget of 0 uncovered routes and counts a route as covered when any file under tests/ or examples/ names it. If the route takes a CNA_Bool, run generate_bool_contract_test.py --write or CApiBoolContractCurrent fails.
  7. Record layout and exports (generate_abi_baseline.py --write --library ..., additions only expected), then check_declared_exports.py --library ..., which compares declared and exported names both ways.
  8. Update the inventory: mapping rule, then generate_coverage_inventory.py --write and generate_limitations.py --write; a route that closes a planned row should lower the count.
  9. Bump the version and update ABI_VERSIONING.md, CHANGELOG.md and the family document (AUDIO.md, GRAPHICS_DEVICE.md, …).
  10. Run the gates below, and the installed-consumer check if headers, exports or packaging changed.

Tests and gates

GateNeedsCatches
CApiAbiHeaderBaseline, CApiCompatibilityMatrix, CApiHeaderCompatibility, CApiBoolContractCurrent, CApiRouteTestCoverage, CApiDocExportCountsOrdinary tests build with Python 3; no C libraryMoved fields or constants, headers not self-contained at C99, a stale generated bool test, routes with no caller, stale export counts
CApiCoverageMatrix, CApiCoverageScopeModel, CApiLimitations, CApiReleaseGatePython 3; the inventory ones also Doxygen and sharp-runtimeAn unmapped public symbol, a stale generated summary, a criterion that disagrees with its measurement
CApiAbiBaseline, CApiDeclaredExports, CApi_ExportsThe built library, ELF or Mach-O (registered on macOS too; each tool decides by the file’s magic)A vanished or unexpected export, a declared route the library does not export, a wrong reported version
90 CApi_*Smoke programs, CApi_TeardownLifetime_* (seven modes), CApi_StressSmoke, CApi_HandleRegistry, CApi_BoundaryDetailCNA_BUILD_C_API=ON with CNA_ENABLE_NET=ONRoute behaviour, refusals, handle recycling, process-exit teardown
CApi_InstalledConsumerC API build, Linux/ELF and macOS, serial, 900 s timeoutAn install unusable from outside the tree, shared and static, without an environment variable
CApi_WasmLinkContract, CApi_WasmModuleSmoke, CApi_WasmBrowserProbeEmscripten 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++ changeInside the tree
Rename, re-type or remove a wrapped public symbolThe 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 identityTwo static_asserts in CnaCApiCoreExt.cpp compare the 14-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 changesAn 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 semanticsA 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 exceptionMay 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 maintained external binding, CNA.NET (cna-dotnet), is a separate repository on its own cadence. It admits exactly one reviewed ABI point, now 0.46.0 (moved from 0.44.0 by a recorded compatibility review at its commit 8219a6c; 0.45.0 was never admitted), so every new minor, additive, incompatible or rule-only, needs a reviewed point there before CNA.NET accepts the library. The eight archived bindings (Common Lisp, Go, Java, Python, Ruby, Rust, Swift and TypeScript, kept in CNA Lab) are frozen at their final revisions and receive no C API change; they stopped at 0.21–0.23 (Common Lisp) and 0.35.0 (the other seven), and none is qualified against this ABI (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 archived Python binding's loader refuses a missing export by name); (c) a rule-only minor changes no export, so only the binding's own refusals, threading and tests reveal it; (d) 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# page (and, as records, on the archived 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_api in a CNA_BUILD_C_API=ON tree 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 searching modules/c-api/src finds no route returning it; a stale handle answers INVALID_HANDLE. Do not document or test behaviour the implementation lacks.
  • Static destruction. The registry is a function-local static in CnaCApiRuntime.cpp that 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 a Game destructor touches must follow that idiom.
  • A finalizer is not a destroyer. A garbage-collected wrapper releasing children on another thread gets CNA_RESULT_THREAD and loses the handle.
  • Counting a mention as a test. CApiRouteTestCoverage passes on a name appearing in any test source; assert behaviour.
  • Caller-created devices. cna_graphics_device_destroy disposes without a child-count check in its route body, and that is the documented contract: graphics_device.h says resources on a caller-created device belong to it and are released with it (GraphicsDevice::Dispose disposes 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: the ABI point CNA.NET admits and whether the change needs a new one, the fact that the archived bindings are not updated, and any removal, renumbering or rule-only widening called out.

Related: Ownership and lifetime master map, Thread and callback map, Blast radius and readiness, Make a release and Change public XNA behaviour.

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

Tests and validation
Test architecture