Python 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 claims were read from cna-python at f085bbb54c57bea553cff9b48898c11f3c49a77d (2026-09-02) and CNA claims from TARGET headers and sources; no build, test or native run was executed, and the binding's loader refuses a TARGET (ABI 0.29.0) library by design.

This page explains how the external Python binding, libcna/cna-python, projects XNA-shaped Python objects onto CNA's C ABI: a measured ctypes manifest, an admission-checking loader, a two-way game callback contract and an explicit ownership state machine. It is written for maintainers who have to change that projection or migrate it to a newer ABI. Every statement about the binding was checked by reading cna-python at commit f085bbb54c57bea553cff9b48898c11f3c49a77d (2026-09-02); every statement about CNA was checked against the TARGET snapshot 009d40f5. Nothing was built, installed or run.

⚠

Not a compatibility claim. At this revision the binding's loader admits exactly C ABI major 0, minor 21 (any patch). The TARGET snapshot exports ABI 0.29.0 (abi.h: CNA_ABI_VERSION_MINOR is 29), so the loader refuses a TARGET library before any game starts. Two of the eight minor steps after 0.21.0 are recorded by CNA as incompatible (0.28.0 and 0.29.0; see the ABI version history). This page is evidence for cna-python@f085bbb, not for TARGET, and native compatibility between the two is not established.

Four concerns, physically separated

Microsoft.Xna.Framework.*        XNA-shaped Python facade and values
  → _cna_native.ownership       handle state machine, parent and borrow checks
  → _cna_native.loader          library selection, admission, route manifest, errors
  → _cna_native.abi + family *_abi.py / *_manifest.py   ctypes layouts, callbacks, prototypes
  → ctypes.CDLL → libcna_c_api → CNA C++

The public Python hierarchy under Microsoft.Xna.Framework carries the XNA-shaped Game, graphics, input, audio, media, storage and content objects. CNA-specific extensions live in a separate package, cna.extensions, whose own docstring lists the families opened at this commit (renderer identity and selection in cna.extensions.graphics, CNB/CNJ content in cna.extensions.content, the engine layer in cna.extensions.engine) and states that nothing there is reachable from Microsoft.Xna.Framework. Extensions are not substitutes for the strict projection. _cna_native is an internal boundary, not application API: no raw handle and no ctypes object is meant to cross into the public packages.

loader.FUNCTION_MANIFEST is a tuple of (symbol, restype, argtypes, ownership note) rows. The core rows (ABI version, error diagnostics, game, window, renderer identity, graphics, audio, storage, …) live in loader.py itself; the loader then appends the media manifest, the CNB/CNJ content manifest (plus a curve-codec slice and a native content-manager slice), and the engine, devices, input and online family manifests. The fourth column is the ownership contract the Python wrapper has to keep ("owned game", "borrowed game", "caller output", "consumes registration", …).

Not every layout module is generated. The core abi.py and cnb_abi.py are reviewed, hand-maintained ctypes layouts whose size, alignment and used field offsets are measured against the C headers by tools/audit_cna_abi.py. devices_abi.py, engine_abi.py, input_abi.py and online_abi.py are generated by tools/generate_family_abi.py, and the devices, input and online manifests by tools/generate_family_manifest.py; both generators have a --check mode that fails when the checked-in copy differs from what the headers produce. The engine and CNB manifests are hand-maintained. Whatever their origin, every imported prototype is proven by tools/verify_prototypes.py, which renders each route from the ctypes manifest as a redundant C declaration in a translation unit that includes the canonical headers, so a C compiler rejects a wrong width, signedness, pointer depth, arity or callback signature. Shared plumbing for the families (checked width conversion, borrowed CNA_StringView, size/copy protocol, rooted callback trampolines) is in _cna_native/family_support.py. A Python feature class cannot safely invent a call signature ad hoc: a route that is not in a manifest is not callable.

pyproject.toml packages the src/ tree with setuptools (≥ 68), declares requires-python = ">=3.10", version 0.1.0.dev0 and the classifier Development Status :: 2 - Pre-Alpha. It builds no extension module and links no C++ symbols; the CNA shared library is an explicit runtime dependency selected by the loader. That is the first thing to remember when debugging "the package imports, but Game cannot start": a successful Python installation says nothing about C ABI availability.

Library admission and diagnostic failures

loader._resolve selects exactly one file, in this order, and never searches build directories on its own:

StepSourceRuleFailure
1CNA_NATIVE_LIBRARYmust be an absolute path to an existing filerelative path: NativeLibraryError; missing file: NativeUnavailableError
2CNA_NATIVE_DIRmust be an absolute existing directory containing the platform name (libcna_c_api.so, libcna_c_api.dylib or cna_c_api.dll)relative: NativeLibraryError; missing directory or no library in it: NativeUnavailableError
3packaged _cna_native/native/the same platform name beside the installed packagenothing configured: NativeUnavailableError naming both variables and the 0.21.x generation

NativeLibrary.__init__ opens the file with ctypes.CDLL, binds cna_get_abi_version first and decodes the result with the same major<<16 | minor<<8 | patch packing CNA uses. abi_is_supported requires SUPPORTED_ABI_MAJOR == 0 and SUPPORTED_ABI_MINOR == 21; the patch may differ. A mismatch raises NativeAbiMismatchError carrying the expected encoding, the actual one and the path, with a message that a different 0.x minor is a different contract. The loader's module docstring explains the policy: a single ctypes manifest cannot be truthful for two 0.x minors that CNA permits to differ incompatibly, so the binding supports exactly the generation it qualifies against.

Only after admission does the loader walk the rest of FUNCTION_MANIFEST, assigning restype and argtypes to every route. A missing export raises NativeLibraryError naming the exact symbol, so a drifted artifact is refused as a whole rather than failing on first use. The one exception is the PENDING_ROUTES table, which at this commit has a single entry: cna_network_session_replace_session_properties. Its recorded reason is that the route is declared in net_sessions.h but seven artifacts checked on 2026-09-02 exported 4,054 of the headers' 4,055 routes and lacked this one. The loader binds it to a _PendingRoute stub that raises NativeUnavailableError naming the route, the artifact and the reason when called, and records it in NativeLibrary.pending_routes. It is not a general allowance to mask absent symbols: tools/audit_cna_abi.py reports pending routes separately, fails if a pending route is not declared in the canonical headers, and fails if the artifact actually exports a route still listed as pending (a "stale" entry).

ℹ

At TARGET the pending route exists in source. cna_network_session_replace_session_properties is declared in net_sessions.h, implemented behind CallWithExceptionBarrier in CnaCApiNetSessions.cpp, listed among the 4,055 exports recorded in abi_baseline.json and exercised by NetSmoke.c. CNA added it in commit 7712534d3 (2026-09-01). This is a source-level reading; no artifact was built here. For a maintainer it means the pending entry will turn into a stale audit failure against any artifact that exports the route, and must then be removed.

Every normal C route returns CNA_Result (uint32_t). NativeLibrary.check(result, operation) returns on 0; otherwise it fills a CNA_ErrorInfo (struct_version 1) through cna_error_get_last_info, reads the message with the size-then-copy pair cna_error_get_last_message_size / cna_error_copy_last_message, and raises NativeError with operation, result, category, native_message and an optional context. Callers may translate that into a more XNA-shaped exception: audio creation maps result 6 (CNA_RESULT_NOT_SUPPORTED) to NoAudioHardwareException, and SoundEffect.CreateInstance maps result 3 (CNA_RESULT_INVALID_STATE) to InstancePlayLimitException. CNA keeps the last error per thread (programming model), so a change to a failure path must keep the diagnostic read on the failing thread, immediately after the failing call; another call or another thread can replace it.

get_library() caches one NativeLibrary in a module global under an RLock. Changing CNA_NATIVE_LIBRARY after the first load does not switch the process to another CNA build; only the test hook _reset_for_tests clears the cache. When CNA_PYTHON_ROUTE_LOG names a file, every bound route is wrapped so the set of routes actually called is appended to that file at exit; the route-reachability gate uses that log as evidence (see Evidence).

Game construction is a two-way callback contract

Microsoft.Xna.Framework._game._NativeGameHost is created lazily by Game._ensure_host the first time the game needs a native handle (Run, RunOneFrame, ResetElapsedTime, SuppressDraw). It records owner_thread = threading.get_ident() at construction and builds two versioned tables with ctypes callback wrappers:

  • CNA_GameCallbacks (struct_version 1): load_content, update, draw, unload_content, exiting;
  • CNA_GameFrameHooks (struct_version 1): initialize, begin_run, end_run, begin_draw (the only one with an extra CNA_Bool* "should draw" output), end_draw.

create() fills CNA_GameCreateInfo with is_fixed_time_step, target_elapsed_time_ticks, the UTF-8 window title as a CNA_StringView and a pointer to the callback table; calls cna_game_create; registers the game with runtime_context; configures the title-storage root; installs the frame hooks with cna_game_set_frame_hooks_ext; pushes inactive-sleep time and mouse visibility; attaches launch parameters, the window and touch; subscribes to game activation/deactivation and window size, orientation and screen-device events; and finally attaches storage and media. Game.Run then calls cna_game_run with the host's C handle; RunOneFrame calls cna_game_run_one_frame.

Every callback wrapper, including the event callbacks, is appended to _callbacks_keepalive for the lifetime of the host. Without that root, Python's garbage collector could free a trampoline while CNA still holds its C function pointer. Callback error messages are kept alive the same way in _callback_buffers until the next run or destroy, so the pointer CNA reads from CNA_CallbackError.message stays valid until CNA has copied it.

The callback path (_invoke) first refuses re-entry after a failure: if pending_exception is already set it returns 9 (CNA_RESULT_CALLBACK) without calling Python. Otherwise it checks the owner thread (a non-owner thread becomes a RuntimeError), enters the graphics-device callback context, drains queued framework callbacks, translates CNA_GameTime into GameTime and calls the Python lifecycle method (Draw also advances the media runtime first). It catches BaseException, stores it as pending_exception, writes "TypeName: message" into the native callback error and returns 9 rather than unwinding a Python exception through C++ frames. _finish_call then re-raises the original Python exception at the Game.Run boundary instead of a generic NativeError; destroy likewise does not emit a second, less accurate error when CNA reports the already re-raised callback failure.

Game.Run may be called once; both Run and RunOneFrame set runtime_context's thread-local current game for their duration and clear it afterwards. Static facades use that context: current_game() answers only on the thread that is inside Run, and live_game() falls back to the single live, undisposed game whose host owner thread is the calling thread, refusing when there is none or more than one. An input or audio call from an unrelated thread is therefore not associated with an arbitrary live Game. This is why callback lifetime and game-thread context belong in any public-API change review (see the thread and callback map). The native side of this contract, including the one-active-game rule and re-entry refusal, is traced in C API internals: game creation.

Read against TARGET's runtime.h, CNA_GameCallbacks, CNA_GameFrameHooks, CNA_GameCreateInfo, CNA_CallbackError and the two callback typedefs have the same members in the same order as the Python layouts at the pin. That is a header reading, not a compiler measurement, and it does not make the pair compatible: the loader refuses 0.29 before any of these structures is used.

Ownership is explicit, not delegated to Python GC

_cna_native.ownership.NativeResource is the single handle state machine. Its Ownership enumeration has three modes: OWNED, BORROWED and PARENT_OWNED. The constructor rejects handle 0 with ValueError and, when given a parent, keeps only a weak reference to it and registers itself with the parent's child list if the parent has one.

  • Use. _require_handle refuses a disposed wrapper, runs an optional set_use_validator hook (a borrowed handle can be valid for less time than its wrapper) and refuses when the parent has been collected or disposed.
  • Release. Dispose runs an idempotent set_before_release hook (so a facade's child views are released on every path, including shutdown), calls the C releaser only for OWNED, then marks the wrapper disposed, zeroes the handle, drops rooted objects and unregisters from its parent. If the releaser raises, none of that happens: the wrapper keeps its handle for investigation or retry.
  • Callback roots. retain_for_registration roots a ctypes callback on the owning handle rather than on the facade, because the facade and its closure can form a cycle the collector reclaims while CNA still holds the raw trampoline.
  • No finalizer. No __del__ calls CNA, because interpreter shutdown may already have unloaded the dynamic library. Context management (with) and explicit Dispose are the release mechanisms.

Game.Dispose releases in a fixed order: dispose every component and clear Components; dispose Content; release the game's microphone facades; enter the graphics-device callback context and unbind Python-side graphics resources; dispose registered native children; leave the context; dispose the graphics manager; finally destroy the native game host (unsubscribe events in reverse, detach touch and media, cna_game_destroy). Errors are collected and the first one is raised after every step has been attempted. The Game's own child list holds strong references and releases in reverse registration order, so an unreachable Python wrapper cannot strand a CNA handle past its parent; the separate ChildRegistry helper in ownership.py keeps weak references for facades that are not ownership roots. An object that depends on another child still needs explicit ordering in its own facade.

A Python object reference does not keep a native child valid after its parent is destroyed: CNA refuses to destroy a game while owned children are alive and invalidates borrowed handles with their owner. Trace the C API's parent/child rules in C API internals: handles and ownership before adding a wrapper, and see ownership and shutdown and the ownership and lifetime master map for the native side.

Representative SoundEffect route

Python SoundEffect(buffer, sampleRate, channels)          (or the 7-argument range form)
  → validate rate, channels, PCM range and loop region; bytes(buffer)
  → _active_native(): thread-local current Game → host → library, game handle
  → CNA_SoundEffectCreateInfo(struct_size, struct_version=1, sample_rate, channels)
  → (c_uint8 * n).from_buffer_copy(payload)
  → cna_sound_effect_create_pcm16_range_ext(game, info, bytes, n, offset, count,
                                             loop_start, loop_length, &handle)
  → NativeResource(OWNED, cna_sound_effect_destroy); game registers the facade as a child
CreateInstance() → cna_sound_effect_create_instance → OWNED child, weakly listed in the effect
Play()           → cna_sound_effect_play(handle, &CNA_Bool) → check result, then read the bool
Dispose()        → child instances in reverse → effect → unregister from Game

SoundEffect copies the PCM payload into a contiguous ctypes array for the synchronous C call; it never asks native code to borrow a long-lived Python buffer (the C route copies the bytes again into a native vector). _active_native uses the thread-local current game from runtime_context, so a sound effect can be created only on the owner thread while Run or RunOneFrame is active, and only once the host has a native handle. The returned 64-bit handle is attached to an owned NativeResource whose releaser calls cna_sound_effect_destroy through NativeLibrary.check.

CreateInstance creates a native child with cna_sound_effect_create_instance, wraps it in an owned NativeResource released by cna_sound_effect_instance_destroy, and records a weak reference in the effect so Dispose can close live instances first. If any instance fails to dispose, SoundEffect.Dispose raises before releasing the effect, so the parent handle is retained rather than leaked into a state CNA would refuse. Play() checks the CNA_Result separately from the output CNA_Bool; the three-argument form uses cna_sound_effect_play_with_settings after narrowing volume, pitch and pan to float32 and range-checking them. FromStream reads the whole stream, validates it in Python as a RIFF/WAVE file with a complete fmt/data pair, mono or stereo PCM16 and 8–48 kHz, and then passes the encoded bytes through a different route, cna_sound_effect_create_from_encoded_ext.

At TARGET, every route in this trace is still declared in audio.h, and the declared parameter list of cna_sound_effect_create_pcm16_range_ext and the members of CNA_SoundEffectCreateInfo match the Python manifest and layout by reading. The native half (validation, copy, parent/child counts, destroy refusal) is traced in C API internals: SoundEffect and instance, and the mixer path in audio engine internals. Python implements no mixing.

Evidence, gaps and migration procedure

The binding separates tests that need a live CNA library from gates that only need headers or a compiler. None of them was executed for this page; the table records what each is written to establish at the pinned commit.

Test or gateNeedsWhat it exercisesWhat it does not prove
tests/test_native.pyCNA_NATIVE_LIBRARY naming an existing file; skipped otherwisea 60-frame game with lifecycle events, Texture2D.FromStream of a PNG, SpriteBatch, viewport and input; every non-touch input route; callback exceptions re-raised at Run in several phases and from Dispose for UnloadContent; live resources disposed before their parent gameanything about a library it did not load (and it cannot load 0.29)
tests/test_audio_backend.pythe same library gate; runs probes in a subprocess with SDL_AUDIODRIVER=dummyreal mixer state transitions, dynamic buffers, capture plumbing and visualization without opening a physical deviceaudible output or physical microphone capture (the docstring says so)
tests/test_loader.pya C compiler (builds tiny fake libraries)unconfigured and relative paths, rejection of 0.7.0, of a later minor and of a different major; a patch inside 0.21 accepted, then refused for a named missing symbolbehaviour of any real CNA build
tests/test_pending_routes.pyno libraryevery pending route is imported, carries a reason, raises when called; an undeclared missing symbol still refuses the librarywhether the route exists in a given artifact
tools/audit_cna_abi.py --cna-root … --library …CNA headers, cc -std=c11, nm -D on an ELF librarycompiles tools/abi_probe.c against the headers and compares sizes, alignments, field offsets and constants with the ctypes side; lists missing, pending, undeclared-pending and stale-pending symbols; exits 1 on any of themcall behaviour or ownership semantics
tools/verify_prototypes.pyCNA headers and a C compilerevery imported prototype as a redundant C declarationthat the route is exported or behaves as documented
tools/verify_route_reachability.pythe package source (AST walk), plus the observed-route log and an admitted listevery bound route has a real call site in shipped code, was observed through CNA_PYTHON_ROUTE_LOG, or is explicitly admitted; names in comments, docstrings or manifests do not countcorrectness of the call
tools/native_ownership_stress.py, tools/audio_ownership_stress.pya configured CNA libraryrepeated real game/resource create-dispose cyclesanything on a library the loader refuses

The binding's README reports two qualified artifacts, both on Linux x86-64 with the SDL3 mixer (HEADLESS and OPENGLES3 renderers) and necessarily of the 0.21 generation, since the loader admits nothing else, and marks Windows, macOS, Android and iOS as not verified. Those are the binding's own claims for its own revision; they are not TARGET results. A passing pure-Python suite cannot substitute for the ABI, prototype and reachability gates.

What the post-0.21 ABI steps touch in this binding

  • 0.29.0 removed cna_sprite_batch_draw_mesh_ext and CNA_SpriteMeshEXT. The binding's generated route census (docs/generated/cna-route-census.json: 4,055 canonical routes, 2,626 bound at this commit) classifies that route as a deliberate non-binding, and no source file at the pin calls it.
  • 0.28.0 retired 25 renderer identities and made their numeric values permanently reserved; TARGET's routes refuse a retired value with CNA_RESULT_INVALID_ARGUMENT (graphics.h, comment above CNA_GRAPHICS_RENDERER_MAXIMUM). The Renderer enumeration in cna.extensions.graphics at the pin lists 39 identity values besides Unknown, 14 of which are among those retired values. That enumeration, and anything that selects or reports a renderer through it, is part of any migration.
  • 0.22.0 to 0.27.0 are described by CNA as additive, but "additive" does not mean "admitted": the loader refuses every minor other than 21 by design.

To migrate to ABI 0.29, in this order:

  1. Diff the TARGET C headers and CNA's ABI versioning record against the 0.21 generation the binding was written for.
  2. Regenerate the generated family layouts and manifests (generate_family_abi.py, generate_family_manifest.py), and re-audit the hand-maintained abi.py, cnb_abi.py, core loader manifest, CNB and engine manifests: structures, callback types, enums (including the renderer identities above) and constants.
  3. Classify every changed ownership, thread and callback contract, and remove PENDING_ROUTES entries the new artifact exports.
  4. Only then change SUPPORTED_ABI_MINOR.
  5. Build or obtain exactly one TARGET library, select it by absolute path, and run audit_cna_abi.py, verify_prototypes.py, verify_route_reachability.py, the native lifecycle tests, the audio subprocess tests and the ownership stress tools against it.

Do not skip from a constant edit to a green unit test. No Python integration run against 0.29 is recorded here; the loader rejects that library by design. The in-tree side of the same procedure is Update the C API, and the ABI conventions any wrapper must mirror are summarised in C API internals: validation and ABI evolution.

Read in this order

  1. src/_cna_native/loader.py and abi.py: establish the exact ABI the projection admits, the resolution order, the route manifest and the wire types; then errors.py for the exception hierarchy.
  2. src/Microsoft/Xna/Framework/_game.py and runtime_context.py: trace native Game creation, callback roots, exception re-raise and the owner-thread static API context.
  3. src/_cna_native/ownership.py: learn dispose, borrow validity and callback retention before adding a resource facade.
  4. Audio/_sound.py and tests/test_audio_backend.py: compare Python's synchronous marshalling with the mixer evidence and its stated limits.
  5. tools/audit_cna_abi.py, tools/verify_prototypes.py, tools/verify_route_reachability.py and tests/test_native.py: learn which evidence is ABI-level and which requires a real runtime library.
  6. For the CNA side of every route above: C API internals, then the sibling projections C# and Java, and C API and bindings architecture for where bindings sit relative to CNA. The user-facing binding table (status and admission rule of every public binding) is in the C API guide.

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

Tests and validation
Test architecture