Java 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 checked by reading libcna/cna-java at 6661173c556bacd1ba3e636fa92d2e4ec330323d (2026-09-01) and are evidence for that revision only; TARGET-side names were checked in the C headers at 009d40f5. Nothing was built or run; the binding admits ABI 0.21.x and TARGET exports 0.29.0, so native compatibility is not established.

This page is a source tour of the external Java binding libcna/cna-java at commit 6661173c556bacd1ba3e636fa92d2e4ec330323d (2026-09-01): an XNA-shaped Java façade, a JNI adapter written in C, and CNA's C ABI underneath. It is for a maintainer who has to change the C ABI and wants to know what a JNI consumer depends on, or who has to migrate the binding. It is not a compatibility claim: the binding is compiled against C ABI 0.21.0 and demands the same major and minor at load time, while the TARGET snapshot 009d40f5 declares 0.29.0 in modules/c-api/include/CNA/C/abi.h.

⚠

Evidence scope. Every statement about the binding below was checked by reading cna-java at 6661173 and is evidence for that revision only. Nothing was built or run for this page: no Gradle task, no JNI compile, no native test (not executed). Between ABI 0.21 and TARGET's 0.29.0 there were two incompatible minor steps (0.28.0 and 0.29.0, see the ABI version history), so native compatibility between this binding revision and the TARGET library is not established. The user-level summary of every language binding is in the C API guide's bindings section.

Why there is a JNI layer

Microsoft.Xna.Framework.*            Java compatibility classes (strict XNA projection)
  → org.openeggbert.cna.internal.NativeBindings / NativeAudio / NativeMedia / …
  → Java native methods  →  src/main/c/cna_java_jni.c  (+ generated/*.inc)
  → dlopen'd libcna_c_api (function table typed from CNA/C/cna.h)
  → CNA C ABI routes → native C++ CNA

The public Java surface mimics XNA-shaped types, but the native contract is CNA's C API rather than any C++ class layout. Java cannot call a C function directly, so every call crosses a hand-maintained JNI shared library, cna_java_jni. NativeBindings.java (about 5,900 lines) holds the game, graphics, content and other route wrappers plus the Java-side bookkeeping between objects and handles; NativeAudio.java groups the audio and XACT routes and the set of game-lifetime audio owners. The handwritten translation unit cna_java_jni.c (about 14,600 lines) marshals Java arrays and callbacks, loads the required C symbols into one dispatch table (CnaFunctions), and translates each Java native method into a C call.

Each dispatch-table slot is declared with CNA_JNI_ROUTE(symbol), which expands to __typeof__(&symbol) of the declaration in <CNA/C/cna.h>. A stale JNI call site therefore becomes a C compile error against the selected headers instead of a runtime crash; the price is that the adapter needs GCC or Clang (MinGW included). The mechanical part of the boundary is generated: tools/native-abi/routes.json lists 1,992 routes in 14 route classes (for example NativeEngineLayerRoutes, NativeCnbRoutes, NativeGamerServicesRoutes), naming only the Java class, the Java method and the C symbol; generate_jni.py derives every JNI type, marshalling step and Java native declaration from the live header and writes src/main/c/generated/ and internal/generated/. A route whose declaration uses a callback, an untyped void* or an array of structs is refused by the generator and stays handwritten. The public façade, the game lifecycle and every ownership decision are handwritten. Editing a generated file without changing routes.json is not durable: generateJniCheck fails when the committed output is stale.

build.gradle's compileJni task builds the adapter with cc -std=c11 -Wall -Wextra -Werror -fPIC -shared (or $CC) against the C headers of a chosen CNA checkout: CNA_ROOT if set, otherwise ../../cnanext relative to the repository root, and a missing modules/c-api/include/CNA/C/cna.h is a configure failure with no fallback. At run time NativeBindings.loadBridge loads the adapter (cna.java.jniLibrary property or CNA_JNI_LIBRARY, else System.loadLibrary("cna_java_jni")), then the JNI loader opens the CNA library: cna.native.library / CNA_NATIVE_LIBRARY (normalized to an absolute path), else cna.native.dir / CNA_NATIVE_DIR plus the platform file name, else the bare platform name (libcna_c_api.so, libcna_c_api.dylib, cna_c_api.dll) through the system loader search path, with dlopen(…, RTLD_NOW | RTLD_LOCAL). The header checkout, the JNI binary and the loaded C library form a three-part compatibility set; compiling the Java classes proves none of it.

ABI and failure boundary

NativeBindings.COMPILED_ABI_VERSION is encodeVersion(0, 21, 0) (the same major<<16 | minor<<8 | patch packing as CNA's CNA_ABI_VERSION_ENCODE), and tools/native-abi/bindings.json records compiledAbi 0.21.0 with an abiPolicy of requireExactMajor and requireExactMinor, citing CNA's rule that an incompatible 0.x change takes a minor increment. requireAvailable() loads the bridge, calls JNI nativeLoadCna, compares the returned major and minor with the compiled ones and throws UnsatisfiedLinkError (“requires ABI 0.21.x exactly”) on a mismatch; the patch component may differ. Every game creation and most static routes call requireAvailable() first.

nativeLoadCna resolves every required symbol before it returns the version: a LOAD(field, name) entry per handwritten route plus the generated routes_load.inc. A missing export throws UnsatisfiedLinkError (“Missing CNA C ABI symbol …”), closes the library and clears the table, so it surfaces at the first requireAvailable(), before any gameplay call reaches the route.

Two result conventions cross the JNI boundary, and they must not be confused:

  • Status routes return the CNA_Result as a Java int. A Java helper (check) turns any non-zero value into CnaNativeException, whose message carries the operation, the numeric result and the C API's thread-local last-error text read through cna_error_get_last_message_size / cna_error_copy_last_message; getResult() exposes the number.
  • Value routes fold the value and the failure into one number: the JNI helpers bool_result, int32_result and uint32_result return the value (for a CNA_Bool, 1 or 0) on success and the negated CNA_Result on failure, and the Java helpers booleanResult / longResult negate it back before building the exception. NativeAudio.nativePlaySoundEffect is one instance of this family, not a one-off.

A create route that succeeds but writes a zero handle, and nativeCreateGame returning 0, are reported as CnaNativeException with result -1: the real CNA_Result of cna_game_create is not carried to Java, only the diagnostic text.

TARGET's ABI 0.29.0 cannot be admitted by this binding as written, and pointing CNA_ROOT at a 0.29 checkout would already fail nativeAbiCheck: by reading verify.py, a manifest compiledAbi that differs from the header ABI is reported as MANIFEST_ABI_DRIFT before any library is loaded (not executed). Changing the constant alone is unsafe: the dispatch table, struct layouts, route inventory, callback signatures, ownership notes and behaviour tests all have to be reviewed against 0.29 headers and a real 0.29 library. As a name-level data point only: all 2,774 functions in the binding's bindings.json manifest are still declared in TARGET's 61 public C headers (4,055 routes), the 11 CNA_GRAPHICS_RENDERER_* constant names it references all still exist there, and it does not reference the cna_sprite_batch_draw_mesh_ext route or CNA_SpriteMeshEXT struct that 0.29.0 removed. That says nothing about signatures, layouts or behaviour, which only the binding's own gates against a 0.29 build can establish.

Handle ownership and the game root

NativeHandle rejects a zero value in its constructor, makes close() idempotent, and distinguishes OWNED, BORROWED, PARENT_OWNED and ADOPTED. Only OWNED and ADOPTED invoke the supplied releaser. surrender() forgets a value without releasing it after ownership moved elsewhere (used by NativeBindings.surrenderResource), so a later close cannot become a double free. The value is zeroed only after the releaser returns, so a releaser that throws leaves the handle live for an explicit retry. At this revision the one production construction of a non-owning mode is the PARENT_OWNED, no-op-releaser handle of a borrowed VideoPlayer frame texture; BORROWED and ADOPTED are exercised by the unit tests. NativeGameHandle is an owned specialization whose release goes through NativeBindings.destroyGame.

The game value is not a raw CNA_Handle. nativeCreateGame allocates a JNI-side JavaGame record (the CNA_Handle, a callback context holding a JNI global reference to the Java Game, and the window and graphics-device event registrations) and returns its address; the JNI entry points cast it back with java_game(value). Resource values such as a SoundEffect are raw 64-bit CNA_Handle values. destroyGame first closes the audio, media and storage owners it tracks, then calls nativeDestroyGame, which disables callbacks, unsubscribes the event registrations and calls cna_game_destroy; the JavaGame record and its global references are freed only on CNA_RESULT_SUCCESS or CNA_RESULT_CALLBACK, which the binding treats as “shutdown completed but a callback reported failure”. Any other result throws and leaves the handle open for a retry (the event registrations are not restored). NativeBindings also keeps WeakHashMaps from Java games, devices, graphics resources, sprite fonts and content managers to handles and owning games. They are bookkeeping, not proof that a native child can outlive its game: the parent-before-child rule of the C ABI handle registry still governs teardown.

There is no universal Java finalizer that safely destroys native handles. NativeDeferredRelease covers XNA types that own a handle but have no public disposal method (at this revision AvatarDescription, LeaderboardEntry, AvailableNetworkSession, NetworkSessionProperties). A java.lang.ref.Cleaner action captures only the handle, the releaser and the creating thread's id, and queues them per thread; the cleaner thread never calls CNA, because the C route answers CNA_RESULT_THREAD off the owner thread. The queue is drained by NativeBindings.updateFrameworkDispatcher(), whose only caller in the main sources is the public FrameworkDispatcher.Update(): releases therefore happen when the owning thread pumps the dispatcher, and a thread that never pumps again never releases (a leak bounded by that thread's life). A failed deferred release is reported after the queue is empty and is not retried. Deterministic close() on the owner thread remains the reliable path for disposable resources; see the thread and callback map and the ownership master map.

One complete Java audio call

Microsoft.Xna.Framework.Audio.SoundEffect(int[] buffer, sampleRate, channels)
  → validate format, block alignment, offset/count and loop range
  → int[] elements (each 0..255) → byte[]
  → NativeAudio.createSoundEffect(bytes, offset, count, rate, channels, loopStart, loopLength)
  → JNI nativeCreateSoundEffect: GetByteArrayElements (pin or copy)
      → CNA_SoundEffectCreateInfo {struct_size, struct_version = 1, sample_rate, channels}
      → cna_sound_effect_create_pcm16_range_ext(game CNA_Handle, &info, bytes, …, &handle)
      → ReleaseByteArrayElements(…, JNI_ABORT)
  → native C HandleRegistry → C++ SoundEffect / mixer
  → Java keeps the 64-bit handle; NativeAudio.registerOwner(this)
Play() / Play(volume, pitch, pan)
  → NativeAudio.playSoundEffect → JNI nativePlaySoundEffect
  → cna_sound_effect_play | cna_sound_effect_play_with_settings → CNA_Bool played
  → 1 / 0, or −CNA_Result → CnaNativeException
Dispose()
  → close child SoundEffectInstances in reverse creation order
  → only if all succeeded: cna_sound_effect_destroy → handle = 0 → unregisterOwner

SoundEffect.java projects XNA's byte[] as int[] and refuses any element outside 0..255 while converting it. It validates the format, block alignment, offset and count, and the loop range (a zero loop length becomes the whole selection) before crossing JNI. The JNI adapter fills a versioned CNA_SoundEffectCreateInfo, obtains the array with GetByteArrayElements (the JVM may pin or copy) and releases it with JNI_ABORT after the synchronous C call, so no Java buffer has to stay valid while the sound plays; on the C side the route copies the PCM into its own storage, as traced in the C route for SoundEffect and instance. SoundEffect.FromStream takes a different route, cna_sound_effect_create_from_encoded_ext.

CreateInstance() asks the C API for a child with cna_sound_effect_create_instance and retains it in the parent's list. Dispose() closes those children in reverse order and destroys the effect only if every child closed; a failure keeps the parent handle rather than discarding it, and the Java handle is zeroed only after cna_sound_effect_destroy succeeds. The ordering is observable because the C API refuses to destroy a parent with live children. On game shutdown NativeAudio.closeAllForGameShutdown closes every registered audio owner (SoundEffect, DynamicSoundEffectInstance, AudioEngine) and unsubscribes audio event registrations before the game handle is destroyed. The owner set is identity-hash based, so its iteration order is not creation order; parent-before-child safety comes from SoundEffect.Dispose closing its own instances first, not from that order. The C routes named in this trace, the structs CNA_SoundEffectCreateInfo, CNA_GameCallbacks, CNA_GameFrameHooks, CNA_GameCreateInfo, CNA_CallbackError, and the results CNA_RESULT_THREAD (8) and CNA_RESULT_CALLBACK (9), are all still declared at TARGET in modules/c-api/include/CNA/C/audio.h, modules/c-api/include/CNA/C/runtime.h and modules/c-api/include/CNA/C/abi.h; that is a source-level name check, not a layout or behaviour comparison. The deeper voice → mixer → device path is in Audio engine internals.

Game callback boundary

NativeBindings.createGame calls requireAvailable(), then nativeCreateGame with the Java Game, its UTF-8 title bytes, the fixed-time-step flag and the target tick count. The JNI side takes a global reference to the game object and caches the method IDs of its private trampolines: nativeInitialize, nativeLoadContent, nativeBeginRun, nativeUpdate(JJZ), nativeBeginDraw()Z, nativeDraw(JJZ), nativeEndDraw, nativeEndRun, nativeUnloadContent, nativeExiting and nativeWindowEvent(I). It then builds a version-1 CNA_GameCallbacks (load, update, draw, unload, exiting) and CNA_GameCreateInfo, calls cna_game_create, installs a version-1 CNA_GameFrameHooks (initialize, begin/end run, begin/end draw) through cna_game_set_frame_hooks_ext, and subscribes three window events; a failure at any step destroys the partly built game. The boundary is bidirectional: Java enters C through cna_game_run, and the native loop calls back into Java, attaching a foreign native thread to the JVM if needed and detaching it afterwards.

Java exceptions do not propagate through these frames. For a lifecycle callback, capture_java_exception clears the pending exception, copies its toString() into a per-game buffer, hands that text to CNA in CNA_CallbackError and returns CNA_RESULT_CALLBACK; at TARGET, RecordCallbackFailure in modules/c-api/src/CnaCApiRuntime.cpp stores such a message as the calling thread's last-error diagnostic, and the C++ side stops invoking further callbacks. On the Java side Game.Run() then sees a failed cna_game_run and throws CnaNativeException with that text: the original Java exception object is not rethrown. Window-event exceptions are cleared the same way without a result; listener failures that the Java window recorded are rethrown after Run() / RunOneFrame() returns. Audio buffer-needed and buffer-content-lost callbacks clear and drop any Java exception. A signature or lifetime change therefore has to be reflected in five places: the public Java Game method, the Java trampoline, the JNI method descriptor, the C callback structure and the native route. The C++ side of this contract is described in the C game creation and frame trace.

A safe maintenance route

For a new native route, start at the CNA C header and C implementation. Add the symbol to bindings.json with its ownership note (add_binding.py reads header, return and parameter types from the live headers; only the ownership prose is supplied by hand), and to routes.json when it belongs to a generated family; regenerate with generateJni; update the handwritten JNI, dispatch table and Java wrapper where needed; classify each returned handle as owned, borrowed, parent-owned or adopted; and add a live JNI test plus an ownership test. For a C signature change, set CNA_ROOT to the verified CNA checkout and CNA_NATIVE_LIBRARY to the library built from it, then run Gradle check and test against that same revision. The C-side half of such a change is in Update the C API.

The Gradle build makes the native inputs explicit. The test task declares the JNI library and, when set, the CNA_NATIVE_LIBRARY file as task inputs, so a rebuilt CNA library invalidates a previously green result (the build comment records that an ABI migration once looked green this way). It forwards CNA_GRAPHICS_RENDERER as an environment variable and an input, because CNA reads it with getenv inside the native library (at TARGET in modules/core/src/GraphicsRendererSelection.cpp; see runtime renderer selection), and it forwards DISPLAY, SDL_VIDEODRIVER and WAYLAND_DISPLAY (blanking the last when only an X11 display is set). -PcheckJni adds -Xcheck:jni. check depends, besides test, on nativeAbiCheck, nativeAbiToolTests, nativeCoverageCheck, nativeCensusCheck and generateJniCheck (plus the API leak guard, the API-tool tests, a compile probe and Javadoc):

GateWhat it checks (by reading its source)
nativeAbiCheck (verify.py)Manifest ABI equals the header ABI; COMPILED_ABI_VERSION equals the manifest; every manifest symbol is loaded by the JNI adapter and nothing unmanifested is; manifest signatures match the headers; probe.c compiles with fixed-width, layout and function-pointer assertions; with CNA_NATIVE_LIBRARY, every symbol is exported and the loaded ABI satisfies the policy.
nativeAbiToolTests (test_verify.py)Negative mutations: a changed parameter type, return type or header, a removed symbol, and a manifest/JNI mismatch must each be rejected with a named diagnostic.
nativeCoverageCheck / nativeCensusCheck (coverage.py)Every canonical C function is either reached from Java (derived from the JNI call graph and Java call sites) or carries an explicit rule; every unbound CNA-extension route names what blocks it.
generateJniCheckThe committed generated JNI and Java files match what the generator produces from the selected headers.

Tests named at this revision (present in the repository; none executed for this page):

  • NativeHandleTests: zero rejection, owned and adopted release exactly once, borrowed and parent-owned never release, a failed release stays open for an explicit retry. Pure Java, no native library.
  • AudioNativeIntegrationTests: the live audio bridge on a null-audio runtime, including instance transport, a SoundEffect still alive at game shutdown, and a wrong-thread close() that must fail with result 8 (CNA_RESULT_THREAD) and leave the effect undisposed until its owner thread closes it.
  • CoreRuntimeNativeIntegrationTests: TitleContainer title-relative read-only streams, and FrameworkDispatcher.Update requiring and pumping the current native game.
  • NativeShutdownSubprocessTests: JVM exit with a live effect, buffer and callback graph, isolated in a child process. It encodes a binding-recorded upstream defect (JAVA-UPSTREAM-014, reproduced without Java by probes/exit_with_live_graph.c): on the renderer identities served by EasyGL, a process that exits while a vertex buffer is alive and its creating thread has ended aborts in a static destructor, and the test asserts that exact abort. This describes the CNA revision the binding was qualified against; whether it still reproduces at TARGET was not examined.

The native suites are gated with @EnabledIfEnvironmentVariable(named = "CNA_NATIVE_LIBRARY"), so without a library they are skipped, not passed. They cannot establish compatibility with the TARGET library while the 0.21/0.29 admission gate refuses it; read the ABI tool's negative mutations as well as a green unit suite. The binding's own migration record, docs/cna-abi-migration-evidence.md (0.7.0 → 0.20.0, with a 0.20.0 → 0.21.0 addendum), shows the shape of the review a 0.29 migration would need. Its README.md still names ABI 0.20.0 where the code says 0.21.0; the code and bindings.json are the authority.

Curated source route

All links are to cna-java at 6661173c556bacd1ba3e636fa92d2e4ec330323d.

  1. build.gradle, bindings.json and tools/native-abi/README.md: establish the three binary inputs, the ABI policy and the gates before reading features.
  2. NativeBindings.java (requireAvailable, loadBridge, configuredCnaLibrary, createGame, destroyGame) and cna_java_jni.c (CnaFunctions, nativeLoadCna, nativeCreateGame, capture_java_exception, nativeDestroyGame): follow admission, game creation and JNI↔C routing.
  3. NativeHandle.java and NativeDeferredRelease.java: understand transfer, thread affinity and retry before writing a wrapper.
  4. SoundEffect.java and NativeAudio.java: trace a feature from façade to JNI while checking parent/child release, then compare with the in-tree C route.
  5. NativeHandleTests.java and AudioNativeIntegrationTests.java: distinguish local lifetime properties from live integration evidence.

The same C ABI seen from other runtimes: C# binding internals (a managed SafeHandle layer) and Python binding internals (ctypes). How the bindings sit relative to the C API as a whole: C API and bindings architecture; the ABI's own validation rules: C API validation and evolution.

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

Maintainer workflow
Update the C API
Tests and validation
Test architecture