C++ SDK
omniloop-sdk-cpp puts the control plane where the controller is. Where the Python SDK reaches training code and the ROS 2 layer, this reaches the loop that actually commands the machine — in C++, at 1 kHz, on a thread you cannot afford to block.
It is a Rust static library with a stable C ABI and a header-only C++17 wrapper. Consumers do not need a Rust toolchain if you ship the built library.
cd omniloop-sdk-cppcmake -B build -S . -DCMAKE_BUILD_TYPE=Releasecmake --build buildctest --test-dir buildOr from the repo root: just build-cpp and just test-cpp.
To consume it from your own project:
add_subdirectory(path/to/omniloop-sdk-cpp)target_link_libraries(my_controller PRIVATE omniloop::omniloop)The CMake target carries the include directory, the C++17 requirement, the static library, and the platform libraries Rust’s std needs (pthread dl rt m on Linux; ws2_32 userenv advapi32 bcrypt ntdll synchronization on MSVC).
A complete controller
Section titled “A complete controller”#include <omniloop/omniloop.hpp>
omniloop::Config cfg;cfg.halt_policy = omniloop::HaltPolicy::Handoff; // never stop actuatingcfg.halt_timeout = std::chrono::seconds(5); // deadman on a lost operatorcfg.publish_every = 10; // 1 kHz loop, 100 Hz framescfg.journal = "run.omni";cfg.journal_rotate_bytes = 256u * 1024 * 1024;
omniloop::Loop loop(cfg);
// Live sliders bound straight to your members. No copy, no marshalling,// no `if (name == "kp")` ladder.loop.tunable("kp", kp_, 0.0, 500.0, 1.0, "Gains");loop.tunable("kd", kd_, 0.0, 50.0, 0.1, "Gains");
// Hard envelopes. Unlike a slider range, these are enforced inside this// process on every write into that memory — including writes your own code makes.loop.limit("kp", 0.0, 400.0);
// Measured values: published and watchable, but nothing outside this process// can set them.loop.readout("tracking_error", tracking_error_);loop.readout_array("joint_torque", torque_, "Joints");
// Tripwires.loop.watch_nonfinite();loop.watch_array_above("joint_torque", 100.0);loop.deadline(std::chrono::microseconds(1000));
// Warm up before raising to SCHED_FIFO — see the RT notes below.for (int i = 0; i < 200; ++i) { auto t = loop.tick(); }
while (running_) { loop.step([&] { controller_.update(dt); }, // normal body [&] { damping_.hold(dt); }); // fallback controller}step() is the safe-halt contract in one call: it opens the tick, asks the barrier what to do, runs the body or the fallback accordingly, turns an escaped exception into a journaled fault that hands over rather than dropping the machine, and closes the tick even when the body throws.
Binding is zero-copy
Section titled “Binding is zero-copy”Every bind/tunable/readout hands OmniLoop the address of your variable. The tick path dereferences it directly: reads for the frame and for watchpoints, writes for inbound edits. Nothing is copied, and there is no shadow copy to keep in sync.
The contract is that the address outlives the loop and stays 8-byte aligned. Both are checked once at registration; null and misaligned pointers are refused there rather than dereferenced later.
| Call | Writable by the control plane? | Visible to watchpoints? |
|---|---|---|
tunable(name, var, min, max) |
yes, clamped by any limit |
yes |
bind(name, var) |
yes | yes |
toggle(name, var) |
yes | yes |
readout(name, var) |
no | yes |
readout_array(prefix, data, n) |
no | yes |
readout registers the pointer read-only in the core, so a dashboard edit, an MCP call and a replayed mutation all fail to reach it. Use it for every measured quantity: a joint torque is the most valuable thing to watch and the most dangerous thing to let anyone set.
Vectors
Section titled “Vectors”Robot state is vectors. readout_array("joint_pos", pos) binds joint_pos_0 … joint_pos_N, and watch_array_within("joint_pos", -2.0, 2.0) arms the whole envelope in one call.
This arms one group watchpoint per bound rather than one per element, so the array shares a latch: an excursion is a single event, not one per joint that wobbles at the same moment. A trip still names the element that tripped. Bind the array before arming — a group matching nothing is an error, because a tripwire covering zero variables reads as armed and protects nothing.
Read back what is armed with loop.watchpoints_json().
Real-time accounting
Section titled “Real-time accounting”The hot path is ol_tick_begin → ol_halt_poll → ol_tick_end. Once warm, those three together:
- perform no heap allocation;
- take no lock another thread holds for an unbounded time;
- do no file or socket I/O on the calling thread.
How each is achieved:
| Concern | How |
|---|---|
| Frame text | Rendered into a String reserved at setup from the channel count. Grows during the first few ticks, then never again. |
| Name escaping | Channel names are validated at registration to contain nothing JSON would escape, so publishing never inspects them. |
| Value sync | sync_floats_from_pointers updates pre-primed f64 slots in place. The string-keyed registry — two allocations per changed value per tick — is skipped entirely. |
| Publish | A seqlock write behind an uncontended parking_lot::Mutex: one atomic swap in the single-publisher case, which is the only supported case. |
| Journal | The loop hands a buffer to a background writer thread. The file I/O is not on the loop thread. |
| Telemetry rate | publish_every decimates frames. Tripwires still evaluate every tick. |
Two things on the path allocate conditionally, both driven by a human rather than by the steady state:
- Draining an empty command ring allocates nothing. Draining a command that actually arrived parses JSON, so a slider drag costs a few allocations on the tick that receives it.
- A watchpoint trip renders the offending value. Trips are edge-triggered, so this is once per excursion, not once per tick.
Neither is avoidable without deferring operator input by a tick, which would trade a real property for a cosmetic one. They are documented rather than hidden.
Two rules that follow from this
Section titled “Two rules that follow from this”- Bind every channel before the first tick. A later
bindis refused, because it would resize the frame buffer on your control thread. - Spin the loop a few hundred iterations before raising priority. The buffers settle during warm-up. The example does this explicitly.
What is still not characterized
Section titled “What is still not characterized”Honest gaps, so nobody plans around a number that does not exist:
- No published jitter distribution on ARM under load, and no p99.9. The figures on the Performance page are x86 desktop.
- Nothing here calls
mlockall, sets thread priority, or pins a CPU. That is your process’s business, and OmniLoop does not do it behind your back — but it also means the RT claims above assume you have done it. - The allocator is whatever your process uses. “No allocation on the tick path” is a claim about OmniLoop’s code, not a guarantee about a body that allocates.
Journal size
Section titled “Journal size”This SDK folds the declared parameter schema onto every published frame — the live channel is a single-slot latest-value, so a dashboard attaching late would otherwise never see it — but strips it from the journaled copy, where session_start already records it once. On the bundled example that is the difference between 652 KB and 196 KB for four seconds of 100 Hz frames.
Set journal_rotate_bytes on hardware. A control loop journals for hours, and an unbounded single file is how a robot fills its own disk mid-test. Rotated segments read back as one continuous run.
Replay
Section titled “Replay”const std::uint64_t ticks = loop.attach_replay("run.omni");for (std::uint64_t i = 0; i < ticks; ++i) { loop.step([&] { controller_.update(dt); }); // the same controller}const auto s = loop.replay_summary();if (s.verdict() != omniloop::ReplayVerdict::Verified) { std::printf("first divergence at tick %llu\n", (unsigned long long)s.first_divergence_tick());}Each tick the mutations the original run received are fed back in place of whatever is on the command ring, so a live edit cannot steer a replay, and each recorded hash checkpoint is compared against the replayed one.
Read verdict(), not the counts. CannotVerify — every checkpoint
disagreeing — means the comparison itself is invalid, not that the loop broke
on tick one. The hash covers only the bound channels, because pid and tick
duration differ between a recording and its replay by construction. A journal
recorded without hash_every is refused rather than silently verified.
The C API
Section titled “The C API”For C, or for a C++ codebase that would rather not take the header:
#include <omniloop/omniloop.h>
ol_loop_config_t cfg = {0};cfg.struct_size = sizeof(cfg);cfg.halt_policy = OL_HALT_HANDOFF;cfg.halt_timeout_s = 5.0;cfg.publish_every = 10;
ol_loop_t *loop = ol_loop_open(&cfg);if (!loop) { fprintf(stderr, "omniloop: %s\n", ol_last_error()); return 1; }
ol_bind_f64(loop, "kp", &kp);ol_set_limit(loop, "kp", 0.0, 400.0);ol_observe_f64(loop, "tracking_error", &err);ol_watch(loop, "tracking_error", OL_WATCH_GREATER_THAN, 0.5);
while (running) { ol_tick_begin(loop); ol_halt_t h = ol_halt_poll(loop); if (h.action == OL_ACTION_RUN_SAFETY) { damping_hold(dt); } else { controller_update(dt); ol_step_barrier(loop); } ol_tick_end(loop);}ol_loop_close(loop);Conventions:
- Fallible calls return
OL_OK(0) or a negativeOL_ERR_*. The message is available fromol_last_error()until the next call on that thread, and is never null — an empty string means nothing failed. struct_sizeis the ABI version marker. A library newer than your header detects the shorter struct and refuses to open rather than reading an uninitialised safety setting out of the gap.- Rust panics never cross into C++. Every entry point runs inside
catch_unwindand converts a panic intoOL_ERR_PANIC. - A null handle is a no-op or an error return, never a crash.
ol_halt_poll(NULL)reportsOL_ACTION_RUN: instrumentation that failed to open must never be able to stop a robot.
What works, and what does not yet
Section titled “What works, and what does not yet”Everything the dashboard and the MCP server drive against a Python loop works against a C++ loop, because both SDKs share one core: live mutation, halt/step, watchpoints, hard limits with readback, the flight recorder, .omni journals with hash checkpoints, and channel health counters.
Not yet implemented for C++:
| Gap | Notes |
|---|---|
| Framework adapters | The Python integrations (SB3, Isaac Lab, ROS 2, …) have no C++ counterparts. A C++ loop binds its own variables, which is usually what you want anyway. |
Channel widths
Section titled “Channel widths”Instrumenting a controller must not change its data model. A mode enum, a cycle
counter and an interlock are ints and bools in every controller anyone
actually writes, and requiring them to be redeclared as double to be visible
was the instrumentation dictating the code.
bind and readout are overloaded, so the width comes from the variable:
std::int32_t control_mode = 0; // an enum, as you already declare itstd::int64_t cycle_count = 0;bool estop_engaged = false; // one byte, not a double
loop.bind("control_mode", control_mode);loop.bind("cycle_count", cycle_count);loop.bind("estop_engaged", estop_engaged); // ol_bind_b8: writes one byte| Declared type | C entry point | Frame renders as |
|---|---|---|
double |
ol_bind_f64 |
number |
std::int64_t |
ol_bind_i64 |
integer, no fractional part |
std::int32_t |
ol_bind_i32 |
integer |
bool |
ol_bind_b8 |
true / false |
double used as a flag |
ol_bind_bool (bind_flag) |
true / false |
Values still travel through the core as double, because that is what
watchpoints, limits and the frame already speak. Widening is exact. Narrowing on
the way back in is range-checked: an edit of 3e9 into an int32_t channel
is refused and reported as applied: false, never wrapped and never saturated —
a silently clamped value is a number in your controller that nobody asked for.
Named arrays
Section titled “Named arrays”readout_array produces joint_pos_0 … joint_pos_27, which is a number you
decode against a URDF every time you read a trace. readout_array_named binds
the names the machine actually uses:
static const char* kLeg[] = {"hip_pitch_L", "knee_L", "ankle_L"};loop.readout_array_named("joint_pos", pos.data(), 3, kLeg, "Left leg");loop.watch_array_within("joint_pos", -2.0, 2.0); // still arms all threeThe set still behaves as one group — one registration, one shared latch, and a trip that names the element — because membership is declared rather than recovered by parsing an index out of the name. Bind before declaring: a group naming an unbound channel is refused, since a tripwire covering zero variables reads as armed and protects nothing.
Read-only builds
Section titled “Read-only builds”cmake -B build -S . -DOMNILOOP_READONLY=ONCompiles out every write into registered memory. Telemetry, tripwires, the
flight recorder and journaling are unaffected; sync_to_pointers becomes
unreachable and every binding registers read-only regardless of the kind
requested.
if (!omniloop::Loop::readonly_build()) { // This build can be commanded. Refuse, if it was not supposed to be.}The distinction from a readout registration or the MCP server’s observe mode
is the point: those are decisions a running process makes. This is a property
of the binary, which is what makes “could a dashboard, an agent, or a bug
command this machine” answerable at build time. See SAFETY.md.
Related
Section titled “Related”- Halt & Step — the safe-halt contract in full.
- Limit Enforcement — how the envelope is enforced in-process.
- Watchpoints — the tripwire conditions.
- Performance — measured per-tick overhead.