Skip to content

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.

Terminal window
cd omniloop-sdk-cpp
cmake -B build -S . -DCMAKE_BUILD_TYPE=Release
cmake --build build
ctest --test-dir build

Or 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).

#include <omniloop/omniloop.hpp>
omniloop::Config cfg;
cfg.halt_policy = omniloop::HaltPolicy::Handoff; // never stop actuating
cfg.halt_timeout = std::chrono::seconds(5); // deadman on a lost operator
cfg.publish_every = 10; // 1 kHz loop, 100 Hz frames
cfg.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.

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.

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().

The hot path is ol_tick_beginol_halt_pollol_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.

  1. Bind every channel before the first tick. A later bind is refused, because it would resize the frame buffer on your control thread.
  2. Spin the loop a few hundred iterations before raising priority. The buffers settle during warm-up. The example does this explicitly.

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.

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.

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.

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 negative OL_ERR_*. The message is available from ol_last_error() until the next call on that thread, and is never null — an empty string means nothing failed.
  • struct_size is 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_unwind and converts a panic into OL_ERR_PANIC.
  • A null handle is a no-op or an error return, never a crash. ol_halt_poll(NULL) reports OL_ACTION_RUN: instrumentation that failed to open must never be able to stop a robot.

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.

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 it
std::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.

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 three

The 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.

Terminal window
cmake -B build -S . -DOMNILOOP_READONLY=ON

Compiles 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.