Create a typed IMC API¶
This guide shows how a native mod can expose a typed, in-process API to another mod. Read Inter-mod communication first for the transport, threading, compatibility, and lifetime model.
The examples require an installed BML+ SDK, CMake 3.15 or newer, a C++20 compiler, and Python 3.10 or newer. New interfaces define RPCs and Topics.
1. Write the interface¶
Name the file after the API ID so the default generated filename is obvious:
api/example.echo.imc.
api example.echo 1.0
enum echo_mode {
normal = 0
uppercase = 1
}
rpc echo(string text, enum<echo_mode> mode) -> (string text)
topic changed(int sequence, string text)
The file is a small IDL, not JSON. Whitespace separates declarations; commas
and semicolons are optional. Both # comment and // comment are accepted.
Only enum values need explicit numbers. Field IDs are assigned by
the generator and frozen in the adjacent example.echo.imc.lock. Do not
write transport bookkeeping in the source interface.
Inline request, response, and Topic payloads are preferred when used by one
endpoint. The generator names the corresponding value types EchoRequestValue,
EchoReplyValue, and ChangedEventValue. Use a named record when several
endpoints share a payload:
record object_query {
object target
optional string label
}
rpc inspect(object_query) -> (string description)
A zero-input RPC uses empty parentheses: rpc state() -> runtime_state.
Add optional before a field type when evolving an existing record:
optional string label.
Omit -> response when the RPC only reports success or failure:
rpc clear_cache(). The generated client returns the RPC status directly and
does not allocate, register, encode, or decode an empty response payload.
The native authoring surface maps directly to the runtime:
.imc entry |
Generated IMC surface |
|---|---|
rpc name() -> response |
zero-input Call* RPC |
rpc name(request) -> response |
request/response Call* RPC |
rpc name(request) |
response-less Call* RPC |
topic name(message) |
Publish*/Subscribe* Topic |
Model object lookup as an ordinary request record containing an object field.
Put query/command intent in the RPC name and payload types, not in a transport
kind. One record can consume at most 64 permanent field IDs; the generator
reports this limit at generation time rather than emitting an incomplete codec.
Enum, record, field, enum-value, RPC, and Topic names may use ASCII letters,
digits, _, -, and . and must contain at least one letter or digit. API IDs
use a stricter canonical form: non-empty dot-separated segments containing only
lowercase ASCII letters and digits. This makes both the generated filename and
C++ namespace unambiguous across independently generated APIs.
Names beginning with a digit are supported: generated C++ identifiers receive
a leading _, while the original interface name and route are unchanged.
Codegen rejects punctuation-only names, empty API segments,
non-canonical API IDs, and two names that collapse to the same generated
identifier before the C++ compiler is invoked.
The grammar is closed: unknown declarations and malformed fields fail with the
input path, line, and column. Keep prose or project-specific metadata in
comments or outside the .imc file.
The IMC field vocabulary is:
| Interface type | Generated C++ type |
|---|---|
bool, int, float |
bool, 32-bit int, float |
int64, uint64, double |
std::int64_t, std::uint64_t, double |
string, bytes |
std::string, std::vector<std::uint8_t> |
object, vec2, vec3, mat4 |
BML opaque object/math value |
array<T> |
std::vector<T> for bool, numeric, string, object, and math types |
enum<name> |
generated enum class Name with a fixed integer underlying type |
Wide numeric arrays support array<int64>, array<uint64>, and
array<double>. A byte blob uses bytes; there is no redundant
array<bytes> shape. The CMake helper selects typed IMC generation
automatically. Manual generation uses the same IMC-only generator.
An enum may specify : int, : int64, or : uint64; int is the default.
Values must have unique names and numbers and fit the
selected width. A record refers to it as enum<echo_mode>. Codegen emits
enum class EchoMode and IsKnownEchoMode() but reuses the corresponding
integer wire codec, so this adds no runtime registry, reflection, or wire tag.
Only scalar enum fields are supported; use a numeric array with an API-owned
conversion helper if a concrete interface needs repeated enum values.
Decoding deliberately preserves an unrecognized underlying number instead of
failing the message. Check IsKnownEchoMode(value) before switching when the
sender may be newer. A compatible minor may add named values. It may not remove,
rename, or renumber an existing value, change the underlying type, or introduce
a numeric alias. The interface lock validates these rules. This lets an older
binding receive a newly added value, retain its number, and choose an
API-specific fallback.
Evolve a compatible minor¶
For a same-major update, keep existing records, fields, RPCs, and Topics unchanged. New fields must be optional. Increase the minor version, edit the source, then explicitly update the interface lock through the project target described below:
The adjacent .imc.lock file owns permanent field IDs. It also keeps tombstones
for removed optional fields, so an old ID can never be reused by
accident. Declaration reordering does not change those IDs. The update rejects
a required new field, changed old field or endpoint, removed required field,
changed enum value, or a structural edit without a minor-version increase.
Commit .imc and .imc.lock together. Treat .imc.lock like a lock file:
review its diff, but do not edit it by hand. Ordinary generation and --check
never modify the lock; they fail with an actionable command when it is
missing or stale. Increase the major version for an incompatible revision;
the new major version starts a separate field-ID space.
2. Generate from CMake¶
The installed BML package includes the generator and
bml_target_imc_api(). The helper adds the generated
header to the target, adds its build directory to the include path, and applies
the required C++20 compile feature. It requires Python 3.10 or newer during
configuration, matching the installed generator. An ordinary target build
requires an adjacent, committed .imc.lock; CMake configuration remains
available when that lock is missing so the explicit update target can create it.
find_package(BML CONFIG REQUIRED)
bml_add_mod(EchoMod EchoMod.cpp)
bml_target_imc_api(EchoMod
INPUT "${CMAKE_CURRENT_SOURCE_DIR}/api/example.echo.imc"
)
The default output is
${CMAKE_CURRENT_BINARY_DIR}/bml-imc/example_echo_imc.hpp. By default the
.imc filename must equal the interface's api; when it does not, pass
API_ID example.echo. The helper passes that expected identity to codegen, so
a typo now reports both IDs and the input path instead of surfacing later as a
missing generated header. OUTPUT_DIR can override the generated directory.
The helper also registers one project-level update target for every declared IMC interface:
Run it after an intentional .imc revision, then review and commit the adjacent
.imc.lock diff. A normal configure or build never runs this target and never
modifies interface locks. Missing and stale lock diagnostics report the same
target command, so authors do not need to reconstruct the generator invocation.
For a non-CMake or committed-output workflow, the package exposes the
BML_IMC_CODEGEN path:
python imc_codegen.py \
--input api/example.echo.imc \
--expected-api-id example.echo \
--out-dir generated
--expected-api-id is optional outside the CMake helper but useful in scripts
that predict the output filename. Add --check in CI to fail when a committed
generated header or interface lock is stale. Non-CMake workflows may use
--update-lock in their author-controlled update step. Parse and validation
errors include the responsible input path, including when several interfaces
are generated together.
3. Implement the provider¶
The generated namespace follows the API ID. Provider handlers receive typed values and return an ordinary BML status code.
#include "example_echo_imc.hpp"
namespace Echo = BML::Imc::Generated::Example::Echo;
Echo::Provider g_EchoProvider;
int HandleEcho(const Echo::EchoRequestValue &request,
Echo::EchoReplyValue &reply,
void *) {
reply.Text = request.Text;
return BML_OK;
}
int StartEchoProvider() {
Echo::Provider::Handlers handlers{};
handlers.Echo = &HandleEcho;
handlers.Execution = BML_IMC_EXECUTION_CALLER_THREAD;
return g_EchoProvider.Start(handlers);
}
int StopEchoProvider() {
return g_EchoProvider.Close();
}
Provider::Start is the normal lifecycle path. At least one generated handler
must be non-null. It opens the transport, registers every non-null handler with
the shared Userdata and Execution values, and rolls the transport back if
registration fails. Close removes every route owned by that transport, so
ordinary teardown does not unregister endpoints one by one.
The lower-level Open, Register*, and Unregister* methods remain available
when endpoints need different userdata, different execution modes, or dynamic
registration. They are not required for the common provider lifecycle.
For a response-less declaration such as rpc clear_cache(), the handler is
simply int ClearCache(void *userdata) and the client call is
client.CallClearCache(). With a request, the handler receives
const RequestValue & before userdata; neither form receives a dummy output.
Use caller-thread execution only for short, thread-safe code. A handler that
touches Virtools, BML UI, or other game-thread state must use
BML_IMC_EXECUTION_GAME_THREAD (the generated default).
Start() with no owner ID resolves the native mod from the calling DLL. If one
DLL contains multiple mods, pass that mod's explicit owner ID as the second
argument. Provider::Close also works from one of that Provider's callbacks:
it prevents another dispatch immediately and completes removal after the
outermost callback returns. A generated Provider may therefore be destroyed by
its own game-thread Handler without a retry state machine.
Caller-thread Handlers may run concurrently. Synchronize them before destroying
their shared Provider or Handlers::Userdata from a Handler. The lower-level
Unregister* methods still return BML_ERROR_BUSY when called recursively;
retain the Provider and retry an individual unregister after the callback.
4. Call synchronously or asynchronously¶
The ordinary generated method performs the complete typed call:
Echo::Client client;
int status = client.Open();
Echo::EchoRequestValue request;
request.Text = "hello";
request.Mode = Echo::EchoMode::Normal;
Echo::EchoReplyValue reply;
if (status == BML_OK)
status = client.CallEcho(request, reply, 1000);
An optional integration can avoid a speculative call:
bool available = false;
if (client.IsEchoAvailable(available) == BML_OK && available) {
// Show or enable the integration. The normal call still handles unload.
}
IsEchoAvailable is one read-only lookup for the already cached RPC ID. It is
an advisory point-in-time snapshot: the provider may unload immediately after
the check, so CallEcho/BeginCallEcho must still handle
BML_ERROR_IMC_ENDPOINT_NOT_FOUND. It does not enumerate providers or
create a future.
Every generated RPC also has a typed future method. It avoids manual request encoding, raw future handles, payload checks, and result decoding:
Echo::Client::EchoFuture pending;
int status = client.BeginCallEcho(request, pending, 1000);
// A later tick or worker iteration:
if (status == BML_OK) {
status = pending.AwaitResult(reply, 0); // zero means poll
if (status == BML_ERROR_BUSY) {
// Still pending; keep the future and try again later.
}
}
RpcFuture<T> is move-only and releases its raw future automatically. It also
offers GetState, Await, Cancel, GetError, GetResult, and explicit
Release. A Begin* method returns BML_ERROR_BUSY rather than silently
overwriting a future that is still owned by the caller.
Do not perform a nonzero wait on the game thread for pending game-thread work. A zero-time poll is safe.
5. Publish and subscribe¶
std::size_t subscribers = 0;
if (client.GetChangedSubscriberCount(subscribers) == BML_OK && subscribers) {
Echo::ChangedEventValue event;
event.Sequence = 1;
event.Text = "updated";
(void)client.PublishChanged(event);
}
Checking the count first is useful when constructing the typed event itself is expensive. Publishing with no subscribers is also valid.
void OnChanged(int status, Echo::ChangedEventValue *event,
const BML_ImcMessage *, void *) noexcept {
if (status == BML_OK && event) {
// The typed value is borrowed for this callback only.
}
}
Echo::ChangedSubscription subscription;
int status = client.SubscribeChanged(subscription, &OnChanged, nullptr, 256,
BML_IMC_BACKPRESSURE_DROP_OLDEST,
BML_IMC_EXECUTION_GAME_THREAD);
Close() may be called from the subscription's own callback. It prevents new
delivery immediately and completes removal after the outermost callback
returns, so a generated subscription may be destroyed by its own game-thread
callback. Caller-thread callbacks must be synchronized before destroying
shared subscription or callback data. Use DroppedCount() to observe
backpressure loss.
6. Diagnose failures¶
All generated methods return BML status codes. Log both the code and
BML_GetErrorString(status). Generated C++ bindings and the C++ IMC helpers
mark status-returning functions [[nodiscard]], so an accidentally ignored
failure is visible to the compiler. Handle the status normally; use an
explicit (void) cast only for intentional best-effort cleanup. This attribute
has no runtime cost and does not change the low-level C ABI.
The common integration failures are:
BML_ERROR_IMC_ENDPOINT_NOT_FOUND: no provider registered that RPC route;BML_ERROR_IMC_API_MISMATCH: the received payload type does not match the generated endpoint;BML_ERROR_WRONG_THREAD: a pending future was synchronously waited on the game thread;BML_ERROR_WOULD_BLOCK: a bounded queue using FAIL backpressure is full;BML_ERROR_BUSY: an individual provider-route unregister or future replacement would violate lifetime rules.
Close subscriptions and providers before destroying callback userdata. Close clients before unloading the native DLL. Owner cleanup is the final safety net, not the normal lifecycle mechanism.
7. Release checklist¶
Before publishing an API:
- verify that the API, record, field, RPC, Topic, and enum identities are final;
- commit the generated
.imc.lockbeside its.imcsource; - run generation with
--checkin CI when generated headers are committed; - inspect the interface-lock diff for a compatible minor;
- use game-thread execution for every callback that touches Virtools or BML UI;
- choose explicit RPC timeouts and Topic queue/backpressure settings;
- test provider unload while clients, futures, and subscriptions exist;
- log and handle BML status codes instead of treating availability as permanent.