Skip to content

C API Conventions

include/ is the public C API boundary. Keep implementation-only helpers out of public headers. Consumers include maplibre_native_c.h; domain headers under include/maplibre_native_c/ keep declarations maintainable and may be included directly when useful.

include/ # public C API headers
maplibre_native_c.h # public umbrella header
maplibre_native_c/ # public domain headers
src/
c_api/ # exported C definitions and C boundary validation
<subsystem>/ # implementation semantics

The ABI is unstable while mln_c_version() returns 0. Do not add compatibility shims or version-branching code for changed structs or functions during this phase.

The public C header targets C23. ABI-crossing enum types use C23 fixed-underlying enum syntax: int32_t for status values and uint32_t for non-negative domains and masks unless a native ABI field requires another width. A mask whose bits index an open enum domain uses uint64_t, because the mask width limits how many values that domain can hold.

JSON and GeoJSON cross the ABI as UTF-8 bytes, not recursive C structs. Use mln_buffer_view for borrowed input and mln_buffer for owned output. Keep every input length-delimited; do not require a trailing null byte. The parameter contract states whether a view contains UTF-8 text, serialized JSON, or arbitrary bytes. Delegate serialized input to the corresponding MapLibre Native parser or converter, and validate only C-owned shape and lifetime rules. Document the required JSON number representation when native behavior depends on whether a number is an unsigned integer, signed integer, or floating point.

Shape structs for future ABI stability. Option and output structs that may grow use uint32_t size fields. Default constructors populate them. Use field masks or presence booleans for optional values when zero is valid. A runtime event subscription mask is required instead: an empty mask selects no event types, and the default constructor sets every type the library reports.

Prefer scalar fields, pointers with length fields, structs, unions, and opaque handles in public structs—these are friendly to binding generators. Expose borrowed ABI-owned text with a length or provide an explicit copy or drain API. Backend-native handles are opaque void*; document the backend type and field-level requirements on the struct field, and ownership and lifetime on the function that accepts or returns the struct. A backend-native handle is an address the host already owns, so it stays a void* and never becomes a MapLibre handle id.

A copy-out entry point takes a caller buffer, its capacity, and an out-parameter for the required length. It writes the required length before it checks the capacity, so a caller learns the size from a call that could not fit the data.

A null buffer with a capacity of zero is a size probe: the entry point reports the required length and returns MLN_STATUS_OK. This keeps the sizing call distinct from the MLN_STATUS_INVALID_ARGUMENT these functions also use for a missing object, which a caller otherwise cannot tell apart. A non-null buffer whose capacity is too small still reports the required length and returns MLN_STATUS_INVALID_ARGUMENT. Entry points whose output length is a documented constant need no probe.

An mln_buffer_view borrows storage only for the call. Parse or copy accepted input before returning. An mln_buffer owns one contiguous result; its view remains valid until the caller destroys the handle. Each JSON or GeoJSON document crosses as one generic buffer, so bindings copy bytes instead of walking a native value tree. Typed fields that accompany a document stay typed fields on the result.

Preserve loaded style documents byte-for-byte when MapLibre retains the source bytes. Values reconstructed from native state use compact JSON serialization; their whitespace, escaping, number spelling, and object member order are not stable API behavior.

The shipped library links no graphics loader. It defines the EGL entry points it calls, and binds those and the GLES and Vulkan tables to the implementation that the host already loaded. Android and OpenHarmony are the exception, where the loader is part of the platform, at a fixed location every host on it already has.

Three rules follow. A build links a loader into the test harness alone, which drives the graphics API the way a host does. An artifact carries the C API and nothing else that loads, so repackaging it copies no implementation along, and a host that loads its own still runs one: handles that one copy mints are opaque pointers another copy does not own. An artifact carries the C API’s own headers alone, because the headers that a host builds surface descriptors against arrive with the implementation that it loads.

A local stand-in for the implementation, its headers included, reaches the install tree through the CMake loader component, which a full installation and the package both leave out. mise run package-native checks the three rules above on every preset.

Every MapLibre handle type is typedef uint64_t, an opaque id. Each id packs its handle type, a slot index, and a reuse generation, so an id names one object for the life of the process and a released id stays distinguishable from every later one.

MLN_HANDLE_NULL is the null handle for every type. A live id always carries a nonzero type tag, so this value names no object of any type.

Handle entry points report MLN_STATUS_INVALID_ARGUMENT and leave the call without effect for an id that names a released object, an id of the wrong handle type, and a value this library never issued. mln_thread_last_error_message() distinguishes those cases. Because every handle shares one C type, the type tag is what rejects a mismatched handle, so document the handle type each parameter expects.

Handle values are safe to copy, compare, hash, and move between threads, and carry no ownership on their own. Owner-thread rules govern which thread may call with a handle, not which thread may hold one.

The bit layout is internal. Hosts pass handles back as issued, and decoding or synthesizing an id is unsupported.

Make ownership explicit at every boundary.

Struct definitions describe data shape, required fields, and pointer validity. Function comments describe whether input pointers are borrowed, copied, retained, or consumed, and when returned views become invalid.

Borrow host-provided strings and buffers for call-duration inputs. Copy host-provided strings and buffers that outlive the function or native callback.

Store host-provided callbacks and user_data by reference. Document how long they must remain valid on the registering function. Document the invalidation point for returned borrowed pointers.

Give owned handles and scoped resources explicit destroy or release functions. Status-returning functions reject MLN_HANDLE_NULL. Void release functions accept MLN_HANDLE_NULL as a no-op, and accept an already-released id as a no-op, so a host cleanup hook that runs twice stays safe.

Output handle parameters that create or acquire ownership require *out_handle to equal MLN_HANDLE_NULL on entry and preserve live host-owned handles on failure. Document when scoped resource ownership begins, when it ends, and whether completion may happen inline or later.

The runtime and map use a host-pumped, owner-thread model. Runtime creation records the owner thread. Runtime, map, and render session calls that touch thread-affine state validate the owner thread. A standalone map projection owns copied transform state and serializes calls from any thread.

A map shares its runtime’s owner thread. A render session records its own: the thread that attached it, fixed for the session’s lifetime. Attach validates that the map is live rather than that the caller owns it, so a session may be attached, driven, and destroyed on a thread that never touches the map. Session calls from any other thread report the owner-thread status. The host may hand the map handle to the attaching thread by any means, because a handle is a plain value and attach resolves it under the C API’s own lock, rejecting an id that names a released map.

Cross-thread dispatch belongs in public functions designed as enqueueing commands. Document that behavior on the function. Higher-level adapters build threaded models above the C API.

Map state a render session reaches for is enqueued to the map owner thread rather than mutated in place, so resizing a session applies the map’s logical size on the map’s next pump. Renderer observer callbacks are forwarded to the map’s run loop for the same reason, so the events a frame produces are drained by a later mln_runtime_pump() rather than inside the render call.

Graphics contexts that bind to a thread, such as OpenGL, are made current for the duration of a session call and released before it returns, so a host keeps its own context current on the thread that owns the session. A context descriptor may instead give the session the thread’s context: the session then creates that context itself, joins no host share group, and keeps it current between calls. Offer that mode where a target draws for one thread alone, and keep the shared mode the default, so a descriptor that names no ownership behaves as it always has. Attach creates the session’s graphics resources on the calling thread, which is why attach belongs on the thread that draws rather than on the map’s.

On Apple targets each entry point drains its own Objective-C autorelease pool, so a host may pump frames from a thread that never returns to a run loop. Objects that cross the C boundary are retained rather than autoreleased, which keeps them valid after the entry point that produced them returns.

MapLibre’s RunLoop is owner-thread scheduler state. Each owner thread may hold one live runtime. mln_runtime_pump() advances that runtime: it parks the owner thread when asked, then drains the queued tasks, expired timers, and ready I/O it finds, including work enqueued while it runs. The budget bounds one pump’s drain at a task boundary: the first queued task always runs, a task runs to completion once started, and tasks left behind re-arm the wake flag so the next pump continues them without parking. Document a budgeted pump as bounding the task queues alone, because timers and ready I/O are serviced regardless.

One entry point carries both cadence sources: the timeout selects the cadence, with zero for hosts driven by a callback they do not own and a positive value for hosts that own their pump thread and take their cadence from the runtime’s own work. Park-and-wake follows these rules:

  • The C API owns the parking primitive. Wake signals reach the owner thread through runtime state rather than through a host callback, because MapLibre raises them from arbitrary threads while it holds locks that every thread queueing owner-thread work needs.
  • Wake signals set a flag that the pump clears before it returns. Document a pump as advancing the runtime, and require the event drain after every return.
  • Any-thread wake entry points take a handle that carries its own reference to the wake state, never the thread-affine runtime handle.
  • Document each blocking entry point’s deadlock risk, naming the host locks a caller must not hold across it.
  • Queue one event per host-visible outcome. An event whose handling acts on the latest state, such as a render update, coalesces against an unread one.
  • A subscription mask suppresses an event before its payload and message are built. A suppressed event stays out of the queue and raises no wake flag.

Status-returning C API functions return mln_status. Each function’s public comment lists its status values and meanings.

Use these categories consistently:

  • MLN_STATUS_INVALID_ARGUMENT for null pointers, unknown enum values, unknown flag bits, undersized structs, invalid dimensions, handles that are null, released, of the wrong handle type, or never issued, or incorrectly initialized output handles;
  • MLN_STATUS_INVALID_STATE for otherwise valid objects in the wrong lifecycle state;
  • MLN_STATUS_WRONG_THREAD for thread-affine handles called from the wrong owner thread;
  • MLN_STATUS_UNSUPPORTED for backends, platforms, entry points, or requested behavior unavailable in this build;
  • MLN_STATUS_NATIVE_ERROR for native MapLibre errors or C++ exceptions converted to status.

Every exported MLN_API C++ definition must be noexcept. Status-returning entry points use the C API boundary helper to clear thread-local diagnostics on entry and convert exceptions to MLN_STATUS_NATIVE_ERROR.

Set thread-local diagnostic strings for synchronous non-OK returns. Report asynchronous native failures through copied runtime events.

The C API preserves MapLibre Native’s imperative, observer-driven model. C API calls return status for synchronous acceptance or failure; drained events report later native work.

Prefer drained events for native-to-host notifications about map state, lifecycle, rendering, and errors. A host selects the event types it reads with a subscription mask, so document the state that each type carries. Options always read the mask, and a bit outside the documented set of types returns MLN_STATUS_INVALID_ARGUMENT. Use native callbacks for low-level extension points where MapLibre needs a synchronous decision, an asynchronous request handle, or process-global integration such as logging.

Event payloads use plain data with documented lifetimes. Each event identifies its source kind and source handle. Queued events never outlive the source handle they reference: map teardown discards queued events for that map, and runtime teardown discards runtime-owned event streams before the runtime handle becomes invalid. A drained batch holds copies, and it stays readable until the next drain for its runtime.

Classify each operation as one of:

  • immediate, where the return status is the final result;
  • a command, where return status means accepted and later effects arrive as events;
  • a state snapshot, where the returned data is last-known state;
  • a blocking query, used rarely and documented with deadlock risks;
  • an event stream, where many events are expected over time.

Logging, resource transform, and resource provider callbacks may run on MapLibre worker, network, logging, or render-related threads.

A callback API documents:

  • which thread may invoke it;
  • how long the callback and user_data must remain valid;
  • whether input pointers are borrowed or copied;
  • whether output pointers are copied before return;
  • whether it may call back into any C API function;
  • what happens when it returns an error or unknown decision value.

Callbacks must not unwind through the C API. Bindings catch host exceptions, panics, and errors inside the callback and convert them to the callback’s documented return behavior.

Render session APIs document owner thread, render target backend handle ownership, synchronization, borrowed pointer lifetimes, frame generation or stale-frame behavior, and teardown rules. Frame generations are session-scoped counters in frame structs and are unrelated to the generation inside a handle id. Attach entry points also document that the calling thread becomes the session’s owner thread, what the calling thread’s graphics context must provide, and which context ownership modes the target accepts.

include/maplibre_native_c/callback_adapter.h adapts these synchronous callback contracts for host runtimes that cannot meet them. It serves hosts with both of these constraints:

  • Host callbacks are delivered asynchronously and return void, so the host cannot answer a decision the C API needs immediately, and cannot read a borrowed payload that expires when the C callback returns.
  • The host has no native compilation unit of its own, because it consumes the shared library through a pure foreign-function interface.

A host that compiles native code writes this adaptation there instead, in whatever form its runtime prefers, and does not use this header.

The layer answers on the host’s behalf: it copies borrowed payloads into native-owned records the host releases explicitly, decides from native-owned routing tables when a result is needed immediately, and hands records to the host through void listener functions. Its entry points carry the mln_adapter_ prefix and follow every rule in this document, including the callback documentation requirements above.

This header is public but stays out of the maplibre_native_c.h umbrella, so binding generators that target the umbrella do not emit declarations for a layer their host does not need. Bindings that need it name the header directly.

Keep test-only entry points out of this layer, as out of every other public header. A binding that needs to drive native dispatch from its own tests calls these public entry points directly with the state it registered.