ABI stability and API versioning
Status: Implemented (API 1.19). The version layer (stable names, id aliases, deprecation shims,
api_versionranges, renamed mods, golden mods in CI) is implemented since API 1.19. The stable ABI is the set ofclightWASM imports (append-only, namedcl_<fn>), the guest exports, the structs, events and enums, guarded by the ABI snapshot test. Thecl_api_v1table exists inside the engine for in-process runtimes (Lua, tests); mods never see it. Versioned services for mods are Planned.
A CLight mod built once should keep loading on every later engine of the same major API version, without a rebuild. This page explains how the C ABI makes that possible, which rules the engine follows, and which rules your mod must follow to benefit.
Versions involved
| Version | Example | Meaning |
|---|---|---|
| CLight API version | 1.4 | CLIGHT_API_VERSION_MAJOR.MINOR. What this page is about. |
| Game version | 26.3 | The vanilla version Celestial reproduces. A mod’s game range in the manifest. |
| Engine build | seecraft 0.x (git abc1234) |
cl_engine_build(). Never needed for compatibility. |
| Your mod’s version | 1.2.0 | SemVer for your users and dependants (Versioning and dependencies) |
At load, the loader checks two things:
| Check | Rule | Error |
|---|---|---|
The manifest’s "api" major |
Must equal the engine’s major | CLL-2003 (newer) / CLL-2004 (older) |
A WASM module’s clight_mod_api_version() (the headers it was built with) |
Same major, and a minor not newer than the engine’s | CLL-2004 / CLL-2003 |
So a C or C++ mod built with the API 1.4 SDK loads on every 1.x engine from 1.4 on; build with the oldest SDK you want to support. A Lua mod has no build step: it loads on every engine with its major, and probes newer functions at run time with cl.has("level.set_block") (C: cl_core_has). The deprecated "clight" range string still loads (mapped to a major, with a warning; 0.x ranges from the drafts mean API 1).
The compatibility promise
A mod built against any CLight API 1.x keeps loading and behaving the same on every later Celestial with API 1. You never rebuild it for a new Celestial build, and a new game version doesn’t break it either, the same promise Paper and Bukkit make for plugins. In practice:
- Nothing that shipped is ever removed, renamed away or changed in meaning within the major (the rules below).
- When the engine calls something differently, the old name keeps working through the version layer (next section), with at most a one-time warning in the log.
- Old mods are tested on every build: the golden mods below are real mods built once against API 1.0, 1.4, 1.6 and 1.11 and never rebuilt. If one of them stops loading or behaves differently, the build fails.
- A future API 2 may drop what API 1 deprecated, after a whole major of overlap. API 1 mods then get a clear
CLL-2004message instead of silently misbehaving.
The version layer
Mods use stable names: function names (cl_level_set_block, cl.level.set_block), event names ("player.join"), mixin targets and accessors ("entity.living.hurt"), cl.has() feature names and registry ids ("minecraft:short_grass"). Each Celestial build maps those names to what it calls them internally. The map of a build is client/clight/idl/generated/version_map.json (in the SDK as clight-sdk/idl/generated/version_map.json): every stable name, its table index or id, the API version it came with, and what renamed or deprecated names resolve to. Mods never need to read it; tools and people porting mods do.
| When this happens | What keeps your mod working |
|---|---|
| A function gets a better replacement | The old one stays in the import table and forwards to the new one (@deprecated("...") @forward(new) in the IDL generates the shim). The first call from your mod logs CLL-2010: <old function> is deprecated, use <new function> (it keeps working; this warning is shown once). The C header marks it CL_DEPRECATED, C++ [[deprecated]], clight.d.lua @deprecated. |
A Lua spelling changes (API 1.1 made cl.<ns>.<verb> mirror the C names) |
The older spelling (cl.net.buf_write_i32, cl.sched.task_cancel, cl.entity.player_give, cl.item_stack) resolves to the current function on first use, logs one CLL-2010 warning for your mod and is cached: later calls cost nothing. API 1.0 method calls that later became properties (player:name(), player:health(), player:uuid(), player:level()) return what they returned in 1.0. |
An event, accessor or cl.has() feature is renamed |
rename event "old" -> "new"; (and accessor, feature) in compat.idl: the old name resolves to the new one in cl_event_type_lookup / cl.on, cl_accessor_lookup and cl_core_has. |
| A mixin target is renamed or replaced | alias "old" = "new"; and shims in mixins.idl (Mixins). Your injection lands on the new target; filters still match. |
| Vanilla renames a block, item or entity type | id block "minecraft:grass" -> "minecraft:short_grass" @game("1.20.3"); in compat.idl. Lookups by the old id (cl_block_lookup, cl_item_lookup, item ids in stacks and give, entity types in spawns, queries, models, AI and mixin filters) find the new one. Aliases are per registry (minecraft:grass is still a worldgen feature of that name) and never change what the game itself accepts: commands, data packs, saves and packets stay exactly vanilla. |
| Vanilla removes a mechanic entirely | The target or event stays and is marked inert (it loads, never fires), as documented on the target. |
The ids that vanilla renamed and CLight still accepts today:
| Old id | Now | Registry | Renamed in |
|---|---|---|---|
minecraft:zombie_pigman |
minecraft:zombified_piglin |
entity type | 1.16 |
minecraft:zombie_pigman_spawn_egg |
minecraft:zombified_piglin_spawn_egg |
item | 1.16 |
minecraft:grass_path |
minecraft:dirt_path |
block, item | 1.17 |
minecraft:pottery_shard_archer / _arms_up / _prize / _skull |
minecraft:archer_pottery_sherd / arms_up_ / prize_ / skull_ |
item | 1.20 |
minecraft:grass |
minecraft:short_grass |
block, item | 1.20.3 |
minecraft:scute |
minecraft:turtle_scute |
item | 1.20.5 |
minecraft:chain |
minecraft:iron_chain |
block, item | 1.21.9 |
Requiring a minimum API: api_version
"api": 1 says which major your mod is written for. To say which minor it needs, add "api_version":
"api": 1,
"api_version": ">=1.12"
"1.12" alone means the same (>=1.12 <2). An engine older than the lower bound refuses the mod with a message that says what to do: CLL-2003: needs CLight API >=1.12 <2, but this Celestial has API 1.11: update Celestial (the mod uses functions this version doesn't have yet). An upper bound inside the same major (<1.15) is ignored with a warning, because every 1.x engine keeps running every API 1 mod. WASM mods also carry the version of the SDK they were built with (clight_mod_api_version()), so a C or C++ mod gets the same check automatically.
Renaming your mod: former_ids and id_aliases
"id": "gems_plus",
"former_ids": ["gems"],
"id_aliases": { "gems_plus:old_sword": "gems_plus:ruby_sword" }
former_ids: mods that depend on the old id ("depends": { "gems": ">=1.0" }) still find yours,cl_mod_is_loaded("gems")is true, and everygems:<path>registry id resolves togems_plus:<path>.id_aliases: your renamed blocks, items and entity types. Only your own ids (or your former ids) may be renamed.
Golden mods
client/clight/tests/golden/ holds mods written the way their API version taught: a Lua mod and C and C++ mods for API 1.0, a C++ mod for 1.4, a Lua mod for 1.6 and a C mod for 1.11. The C and C++ ones were compiled once with the SDK headers of their version (kept next to them in golden/sdk/api1_X/), and the .wasm files are committed and never rebuilt. ctest clight_golden_tests loads each one on the current engine, runs the same scenario (a player joins, two chat messages, the mod’s command, a hurt through a test mixin target) and compares the transcript with the committed expected.txt. A difference means a change broke old mods; the fix goes into the engine, never into the transcript. The generator’s own rules are tested too (clight_compat_gen_selftest: a shipped rename, id alias or deprecation shim can’t be removed or retargeted).
What the engine promises within a major version
- No removals. Functions, struct fields, enum values, events and capabilities that existed in 1.0 exist in every 1.x.
- No signature changes. A function’s parameters and return type never change. New parameters go into a descriptor struct instead of a second numbered function.
- No meaning changes. Behaviour can be fixed to match vanilla better, but documented semantics stay.
- Additions only at the end. New functions are appended to the native function table; new struct fields are appended after existing ones; new enum values get new numbers.
- Deprecation before removal. A deprecated item keeps working for the rest of the major version and the whole next one, with a one-time
CLL-2010log warning per mod at run time and a compiler warning (@deprecatedin the IDL becomesCL_DEPRECATED("use …")in C,[[deprecated]]in C++, and a log warning in Lua).
These rules are enforced by tests: client/clight/idl/abi/clight_abi.snapshot.json is the committed list of every shipped function (table order and C signature), callback, event id, struct field and enum value, and ctest clight_abi_append_only fails when the IDL removes, reorders or changes any of them (appends pass). After an intentional append, python tools/clight_gen/clight_gen.py --write-snapshot updates the snapshot (it refuses if the change is a break). clight_generated_fresh fails when the committed generated files are stale (The binding generator and CLight IDL).
The mechanisms that make it work
struct_size first
Every descriptor and every out-struct begins with uint32_t struct_size:
cl_block_desc d = CL_BLOCK_DESC_INIT; // struct_size = sizeof(cl_block_desc) as compiled into YOUR mod
- Mod → engine (descriptors): the engine reads only the first
struct_sizebytes. Fields added in later versions take their vanilla defaults. An old mod passing a short struct is therefore fine. - Engine → mod (out-structs, event payloads): the engine writes at most the
struct_sizethe mod set (for out-structs) or provides a payload at least as large as the mod’s view (for events). An old mod never sees fields it doesn’t know.
Always start from the _INIT macro. A struct you memset yourself has struct_size = 0, which the engine rejects with CL_E_INVALID_ARGUMENT.
The import table only grows
WebAssembly mods import functions by name (clight.cl_*), and in-process runtimes call through a function table. Both only grow. A module that imports a function the engine doesn’t have fails to load (CLL-3004, naming the function), which the version check above already prevents for official SDK builds. To use a newer function only when it’s there, ask first:
if (cl_core_has(CL_STR("player_send_title"))) { /* ... */ }
Deprecated functions are marked CL_DEPRECATED("use ...") in C (a compiler warning), [[deprecated]] in C++ and @deprecated in clight.d.lua.
Opaque handles
Handles are 64-bit values with no exposed layout. The engine can change how objects are stored (and it does, for example when entity storage is reorganised for speed) without affecting any mod.
No C++ across the boundary
clight.hpp is header-only and compiled into your mod. The engine’s C++ classes, its STL and its compiler never meet yours. That is why a mod compiled with GCC 14 works on an engine built with Clang 19, and why the C++ wrapper can evolve (new conveniences) without breaking old binaries.
Hooks and events across versions
- Event payloads follow the
struct_sizerule: new fields are appended. A handler written for 1.0 still sees the 1.0 fields correctly. - Events are never removed within a major version. If the vanilla behaviour an event mirrors disappears in a game update, the event keeps existing and is documented as “never fired” until the next major version.
- Mixin targets carry their own version (
since_minor) and can be marked deprecated or inert (Mixins). - New events appear in minor versions (
@sincein the IDL).cl_event_type_lookupreturnsCL_E_NOT_FOUNDon older engines, so a mod can probe for them.
Game updates
When the game version moves (for example 26.3 → 26.4), Celestial follows it. Content ids, data formats and behaviour change where vanilla changed them. The CLight API version doesn’t have to change for that: identifiers are strings, and registries are looked up by name. Mods whose logic depends on version-specific vanilla behaviour declare a game range in the manifest.
Your mod’s own ABI
If other mods depend on your mod (commands, payload channels, block and item ids), treat those as your API: keep ids stable and change them only in a major version of your mod. Versioned services, custom events and .clidl files with a compatibility check are planned (Services and mod interop).
Checklist for mod authors
- Build with the oldest CLight SDK you want to support, and probe for newer features at run time (
cl_core_has,cl.has), or state the minimum with"api_version". - Renamed your mod or its content? List the old names in
former_ids/id_aliasesinstead of breaking the mods and worlds that use them. - Use
_INITmacros for every struct. - Never depend on raw numeric ids, handle bit layouts, or the order of fields you don’t use.
- Treat deprecation warnings as work for your next release.