C# binding internals

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

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Binding facts were read at libcna/cna-cs e223909986a705d0026908155f59388c100cbda3 (2026-09-03), which admits C ABI 0.21.0 only; CNA snapshot 009d40f5 declares 0.29.0. Snapshot facts are limited to its C headers and C API sources. No binding test, probe or build was executed, and native compatibility is not established.

CNA.NET (libcna/cna-cs) is an external C#/.NET binding that reaches CNA only through the experimental C ABI. This page explains how it is built — its layers, its native-loading gate, its handle and string marshalling, its finalizer-safe release queue and its game-callback trampolines — for a maintainer who has to update it or reason about a managed/native ownership bug. Everything here was read at cna-cs commit e223909986a705d0026908155f59388c100cbda3 (2026-09-03); it is evidence for that revision of the binding, not for the CNA snapshot these pages document.

⚠

Not a compatibility claim. At the audited commit the binding admits exactly one native generation, C ABI 0.21.0. CNA snapshot 009d40f5 declares C ABI 0.29.0 in modules/c-api/include/CNA/C/abi.h, and two of the minor steps in between (0.28.0 and 0.29.0) are recorded by CNA as incompatible (see the ABI version history). The binding's resolver therefore rejects a library built from this snapshot before any game runs. Native compatibility between this binding and the snapshot is not established, and nothing on this page was built or executed: the binding sources and the snapshot's C headers were read, no test was run.

The four physical layers

CNA.XnaCompat  (Microsoft.Xna.Framework public hierarchy)
  → CNA.Framework (CNA.* managed objects, ownership, pure values)
  → CNA.Interop   (LibraryImport declarations, ABI structs, loader policy)
  → cna_c_api     (C ABI, logical library name "cna-native") → native CNA C++

The solution has three source projects under src/, and each layer has one job:

  • CNA.XnaCompat is the XNA 4.0 compatibility façade (Microsoft.Xna.Framework.*). Preserving XNA's public hierarchy often requires composition rather than inheritance: the compat Microsoft.Xna.Framework.Audio.SoundEffect is a sealed class that holds a CNA.Audio.SoundEffect, and its SoundEffectInstance holds an inner CNA.Audio.SoundEffectInstance. Its public contract is measured against real XNA metadata by tools/api-compat, not derived from CNA's headers.
  • CNA.Framework owns the managed resource wrappers (NativeResourceHandle), the Game base class and its callback trampolines, value types and CnaException, the only exception type a failing native call becomes. CnaException exposes the failing result only as a string name (NativeResult) because no CNA.Interop type may appear in a public signature.
  • CNA.Interop is internal. Native.cs is the handwritten raw declaration list and, per its own header comment, the only file in the solution that names the native library. Its methods use .NET's [LibraryImport], so the interop source generator emits the marshalling stubs during the managed build — but the route inventory, the parameter widths and the ownership decisions are written by hand, not generated from CNA's headers. At the audited commit it declares 1,002 entry points, and CnaAbiTests.RequiredSymbols_AreEveryDeclaredNativeImport pins that literal as a tripwire so that adding an import is a deliberate act. A new C route therefore never appears in C# on its own.

NativeLibraryResolver sits in CNA.Interop and is registered by a module initializer for the CNA.Interop assembly only. The layers are also a rule: a feature class must not bypass CNA.Interop with its own ad-hoc DllImport, because the loader gate below only covers imports declared in Native.

Native loading is a compatibility gate, not a filename lookup

The resolver binds only the logical name cna-native and answers every other name with “not mine”. Selecting a file and admitting it are two separate stages.

Stage 1: selecting exactly one file

  1. CNA_NATIVE_LIBRARY, if set, must be an absolute path to an existing file. It has the highest precedence, and when it is set no fallback is attempted: a wrong value is an error, not a hint.
  2. Otherwise CNA_NATIVE_DIR, if set, must be an absolute, existing directory containing at least one recognized library name; again no fallback when it is set.
  3. Otherwise the application's own locations: AppContext.BaseDirectory, the directory of the CNA.Interop assembly, and runtimes/<RID>/native (the packaged RID-native asset layout).

Recognized file names are cna-native and cna_c_api with the platform's prefix/suffix (libcna_c_api.so, cna_c_api.dll, libcna_c_api.dylib, …). More than one match in the chosen location is refused as a conflict rather than resolved by preference. The resolver deliberately never probes a source checkout or the process-wide loader search path, so a package consumer cannot succeed merely because a developer build happens to be visible. Every failure message names the selection route, the platform/RID and a remediation; CNA_NATIVE_DIAGNOSTICS=1 adds the operating-system loader's own text.

Stage 2: admission against a reviewed point matrix

After NativeLibrary.Load the resolver, in order: requires the export cna_get_abi_version and calls it; looks the encoded version up in the compiled matrix (CnaNativeAbiPolicy.TryGetProfile); requires every entry point declared by Native to be exported; then runs two side-effect-free runtime canaries — cna_error_get_last_message_size must succeed and write its out-parameter, and cna_touch_capabilities_init must fill a 16-byte, version-1 structure with a canonical body without touching guard bytes on either side. Any failure frees the library and throws DllNotFoundException with the detected and expected ABI.

The executable matrix is compiled into CnaNativeAbiPolicy (ConsumerVersion = 0.21.0, one profile classified exact); the resolver does not read JSON at run time. The machine-readable record is eng/cna-native-abi-policy.json (policyVersion cna-cs-native-abi/1, consumerAbi 0.21.0, acceptedVersions = [0.21.0], default reject), and the test CnaAbiTests.MachineReadablePolicy_MatchesExecutableMatrix keeps the two in step. The JSON also records the evidence behind the one accepted entry (a consumed-surface baseline diff and a C-authority layout/prototype run against 0.21.0 headers), the retired entries 0.6.0, 0.7.0, 0.8.0, 0.19.0 and 0.20.0 with the reason each was dropped, and eleven named admission fixtures (for example unreviewed-0.22.0, missing-required-symbol, changed-required-signature, structurally-incompatible-0.21.0), all expected to reject except the two exact-0.21.0 cases. scripts/Verify-NativeAbiCompatibility.sh runs those fixtures on Linux only.

There is no “same major 0.x” range: the policy text says a 0.x minor is “never accepted as a same-major range”. The retired list shows that additive export availability is not the whole policy either — 0.20.0 was retired although nothing the binding consumes changed, so that the matrix stays a list of reviewed points instead of growing into a range. CnaAbi.EnsureCompatible, which Game's constructor calls before its first native call, re-asserts the matrix at the public boundary in case an alternate host bypasses the resolver.

A maintainer must not simply change the expected version to 0.29.0. The migration is a review: compare consumed prototypes, struct layouts, numeric constants, callback contracts, result codes, ownership and behaviour between 0.21.0 and 0.29.0 (the binding's own tools/coverage/baselinediff.py exists for the export/layout half), add the new point to both the compiled matrix and the JSON with its evidence, then run the admission fixtures and the native suites against a real 0.29.0 library.

What the snapshot's 0.29.0 surface looks like from this binding

A name-level comparison, done by reading and not executing anything: all 1,002 entry points the binding imports at e223909 are still declared by name in the snapshot's 61 public C headers under CNA/C/, and the binding names neither the route removed in 0.29.0 (cna_sprite_batch_draw_mesh_ext and its CNA_SpriteMeshEXT struct) nor any renderer-identity constant (it reads the renderer only as a name string). That is the input to the review above, not its result: a name that still exists can have a changed prototype, layout or contract, and the version gate rejects a 0.29.0 library before the symbol check is ever reached. The routes and constants this page relies on — cna_get_abi_version (abi.h), cna_game_create, cna_game_set_frame_hooks_ext, cna_game_destroy, CNA_GameCallbacks, CNA_GameFrameHooks, CNA_CallbackError (runtime.h), the cna_sound_effect_* routes (audio.h), and CNA_RESULT_THREAD (8), CNA_RESULT_CALLBACK (9) and CNA_RESULT_INVALID_STATE (3) — are all present at the snapshot.

Handles, strings and errors on the wire

  • Handles. CnaHandle stores the C ABI's uint64_t CNA_Handle as ulong, never as a native pointer. Many managed wrappers, however, keep the value inside a pointer-width SafeHandle (NativeResourceHandle stores nint), so conversion back uses the checked AsNint, which throws rather than truncates. The design assumes 64-bit processes; a future 32-bit target or a WebAssembly bridge cannot inherit that assumption. See the native handle registry for what the 64 bits mean (slot plus generation).
  • Strings. CnaStringMarshal.WithStringView UTF-8-encodes a managed string into a stackalloc buffer when it is at most 256 bytes and into a heap array otherwise, pins it only for the duration of the synchronous native call, and passes a pointer-plus-length CNA_StringView (a null pointer for the empty string). Native receives a borrowed view for one call, not an owned NUL-terminated string.
  • Errors. CnaException.ThrowIfFailed reads the calling thread's last native diagnostic through the two-call cna_error_get_last_message_size / cna_error_copy_last_message protocol (CnaError.GetLastErrorMessage, which never throws itself) and builds a managed exception. The diagnostic is thread-local on the native side, so it must be read on the thread that made the failing call, immediately afterwards. The C-side rules are in the C API programming model.

Representative call: managed SoundEffect → native track

new CNA.Audio.SoundEffect(byte[] PCM, offset, count, rate, channels, loopStart, loopLength)
  → managed validation (rate 8000..48000, Mono/Stereo, block alignment, range, loop)
  → Native.cna_sound_effect_create_pcm16_range_ext(CnaAmbientGame.Current, info, bytes, ..., out CnaHandle)
  → native C resource + HandleRegistry → C++ SoundEffect (bytes copied into a native vector)
  → NativeResourceHandle owns the result, releases via cna_sound_effect_destroy
SoundEffect.CreateInstance()
  → CreateNativeInstanceHandle → Native.cna_sound_effect_create_instance(parent handle)
  → managed SoundEffectInstance owns the child handle (release: cna_sound_effect_instance_destroy)
  → Play() → cna_sound_effect_instance_play → C++ mixer voice
Teardown: child Dispose, then parent Dispose, then Game.Dispose

The constructor validates in XNA's order — format (sample rate 8,000–48,000 Hz, mono or stereo), whole-buffer block alignment, the offset/count range, then the loop region — entirely in managed code, so failures are deterministic even before a native game exists. CnaSoundEffectCreateInfo sets its own struct_size and struct_version (1) in its constructor. The byte[] is pinned with fixed only for the duration of the call; the C route copies the PCM bytes into a native vector before returning, so the managed array need not stay pinned during playback. Creation needs the ambient game handle (CnaAmbientGame.Current, set by Game's constructor) because the C ABI has no parameterless audio route.

CreateNativeInstanceHandle returns one new native child handle. The public CNA.Audio.SoundEffect.CreateInstance wraps it once; the XNA-compat SoundEffect.CreateInstance calls the same internal method and composes a new compat SoundEffectInstance around that same handle. The compat instance strongly roots its parent, while the parent keeps only weak references to its children and disposes every still-live child before releasing its own native effect. Creating an intermediate public managed instance and wrapping its handle a second time would give one native handle two owners and a double release.

The ordering is semantic, not memory hygiene. At the snapshot, cna_sound_effect_destroy in CnaCApiAudio.cpp refuses with CNA_RESULT_INVALID_STATE while any instance of that effect is alive, and cna_game_destroy refuses while owned audio resources remain; the native side of this sequence is traced in the C API SoundEffect trace, and the mixer below it in Audio engine internals.

NativeResourceHandle and the owner-thread release queue

NativeResourceHandle is a SafeHandle with a per-resource release delegate and an ownsHandle flag. A borrowed resource must be wrapped with ownsHandle: false — the case it was added for is the video player's frame texture, which the C API documents as valid only until the next call on that player; otherwise the critical finalizer would destroy an object its real owner still controls. Detach gives up ownership, returns the value and marks the wrapper invalid, so neither Dispose nor the finalizer will release it; ContentManager uses it to hand a SpriteFont atlas texture to the font, and compat SoundEffect.FromStream uses it to move a decoded effect into the compat type.

Native handles are creation-thread-affine, and a .NET critical finalizer runs on the finalizer thread, where a direct release would receive CNA_RESULT_THREAD and lose the only copy of the handle. ReleaseHandle therefore releases immediately only when it runs on the owner thread (the managed thread ID captured at construction); off-thread it queues the raw value and release delegate under that owner thread's ID. A failed on-thread release is queued too, so a parent refused because a child still lives is retried later. DrainPendingReleasesForCurrentThread repeats passes over the queue as long as some release succeeds (children first, then the parents they were blocking) and returns anything still failing to the queue instead of dropping it. Counters are exposed through NativeResourceHandle.GetMetrics.

The drain points at the audited commit are all in Game: its constructor, entry to and return from Run(), the direct-call helpers RunOneFrame(), Tick(), ResetElapsedTime() and SuppressDraw(), and Dispose. The per-frame callback trampolines do not drain, so while a game sits inside a long Run() loop, finalizer-queued releases wait until one of those calls, and a wrapper whose owner thread never reaches another drain point leaves a pending native resource. Explicit, deterministic Dispose on the owner thread remains the reliable path. The general thread-affinity rules are collected in the thread and callback map.

Managed Game callback boundary

CNA.Framework.Game's constructor allocates a GCHandle to itself and passes it as the callback context, fills CnaManagedGameCallbacks (load content, update, draw, unload content) inside CnaGameCreateInfo, checks the ABI (CnaAbi.EnsureCompatible), drains pending releases, and calls cna_game_create. It then installs a second table, CnaGameFrameHooks, through cna_game_set_frame_hooks_ext; at the audited commit only its initialize hook is used. This mirrors the snapshot's C design, where frame hooks are a separate table so the original callback struct never grows (see runtime.h). If installing the hooks fails, the game is destroyed and the GCHandle freed before the exception is thrown.

Every callback is a static [UnmanagedCallersOnly] trampoline with the cdecl calling convention that resolves the Game from the context, runs the managed method, and catches every exception: a managed exception must never unwind through C++ frames. ReportCallbackFailure encodes the exception message as UTF-8 (shortened until it fits) into a 512-byte buffer allocated once per Game with GC.AllocateArray(..., pinned: true), points CNA_CallbackError.message at it and returns CNA_RESULT_CALLBACK. The C header documents the message as borrowed only until the callback returns; a buffer pinned for the whole game lifetime removes any doubt that it is still rooted when native copies it.

Game.Dispose releases components, microphone subscriptions, media-player state, window and game event registrations (all of which hold native context pointers into GCHandles), then forces GC.Collect / WaitForPendingFinalizers / GC.Collect so that wrappers whose XNA counterparts are not IDisposable (effect techniques and passes are the recorded case) are finalized, drains the queue, and calls cna_game_destroy once. Disposal must not throw, so a refusal is recorded instead: the handle is kept in a static “undestroyed game” slot with the reason. Because native admits one C-owned game at a time, the next Game constructor that fails to create a game collects, drains, retries the recorded destroy and, if that succeeds, retries creation; otherwise its exception names the earlier refusal. The source comments record why the destroy itself is not retried inside Dispose: a second destroy of a handle the first call may already have consumed is undefined. A callback signature or timing change must be checked in all three places: the native callback structure, the CNA.Interop layout and function-pointer type, and the CNA.Framework.Game trampoline. The native half of game creation and teardown is in the C game trace.

Change workflow and evidence

To expose a new CNA API in C#:

  1. Add and test the native C route first, following I need to update the C API and the checklist in C API validation and evolution.
  2. Mirror the exact primitive widths, struct layout and ownership in CNA.Interop/Native.cs (and bump the 1,002 tripwire deliberately).
  3. Add an idiomatic wrapper in CNA.Framework; add an XNA-compat façade member only if the method belongs to that public contract.
  4. Update the native ABI policy only after a reviewed ABI diff for the new generation, never before.
  5. Run managed, native-integration, ownership-stress, ABI-verification and strict public-metadata checks.

If the route returns a borrow, establish the parent's lifetime and use a non-owning handle; if it returns an owned child, make sure disposal happens before its parent and on its creation thread (see the ownership and lifetime map). Exercise non-ASCII strings, buffer count and stride, wrong-thread release, a missing export and a wrong-ABI library. Changing only a high-level class can hide a stale raw import until its first runtime call.

Evidence group at e223909What it covers
tests/CNA.Framework.TestsManaged behaviour without a native library, including CnaAbiTests (version decoding, matrix acceptance/rejection, required-symbol list, JSON-vs-code policy agreement).
tests/CNA.XnaCompat.TestsThe public XNA façade.
tests/CNA.Integration.TestsReal native calls: ResourceIntegrationTests.SoundEffectInstance_MovesThroughItsStates (instance state round trip), GameLifecycleTests (ABI admission, construct/dispose repeatability, lifecycle callback order, timing round trip) and many more.
tests/CNA.OwnershipStressA separate executable, so a native double free kills only that process: 24 create/use/destroy cycles by default (1,000 with CNA_OWNERSHIP_STRESS_DEEP=1, or a count argument), alternating explicit disposal with abandonment plus forced finalization, with a throwing GraphicsDevice.Disposing handler every tenth cycle; it reads the release and destroy-retry metrics.
tools/abi-verifyC-authority layout, constant and prototype verification of the managed declarations against a CNA include directory, with negative controls.
tools/native-abi-probe and scripts/Verify-NativeAbiCompatibility.shLoader admission in an isolated process (CNA_ABI_PROBE_STATUS=accepted|rejected) and the eleven policy fixtures (Linux only).
tools/api-compatCompiled public/protected metadata compared with legally supplied XNA 4.0 Windows runtime assemblies.
ℹ

A green run is not integration evidence by default. Native integration facts use a [NativeFact] attribute that skips the test when the library cannot be loaded, and an ABI-rejected library surfaces as exactly that load failure. Against a 0.29.0 library the native facts would be skipped, not failed, so a passing test report proves nothing about this snapshot. None of these suites has been run against CNA snapshot 009d40f5; the 0.21-only policy is a real blocker to interpreting such a run as integration evidence. The binding's release-qualification workflow pins CNA headers at a 0.21.0-generation commit and runs its native lanes only when a secret-supplied Linux library is configured.

The binding's README is stale relative to its code at the audited commit: it still says the binding targets C ABI 0.20.0 and resolves 861 imports, where the compiled policy and the JSON say 0.21.0 and the tripwire says 1,002. Use the policy, the tests and the current source as the authority. For where this binding sits among the other external projects, see Bindings boundary and C API and bindings architecture; the Java and Python pages describe the same problems solved with JNI and ctypes.

Reading order at cna-cs commit e223909

  1. eng/cna-native-abi-policy.json, CnaNativeAbiPolicy.cs and NativeLibraryResolver.cs: determine whether this checkout can load the selected CNA library at all.
  2. Native.cs, CnaHandle.cs, CnaStringMarshal.cs and CnaException.cs: inspect wire declarations, marshalling and error conversion before any feature wrapper.
  3. NativeResourceHandle.cs and Game.cs: understand the owner-thread release queue, callback pinning and game teardown.
  4. CNA.Framework/Audio/SoundEffect.cs, SoundEffectInstance.cs, the compat Microsoft/Xna/Framework/Audio/SoundEffect.cs and the in-tree C route trace: follow one call through every ownership layer.
  5. ResourceIntegrationTests.cs, CnaNativeProbe.cs and CNA.OwnershipStress: choose live-native and finalizer-order evidence, after ABI admission, and know when a native fact silently skips.

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

Tests and validation
Test architecture