Custom Messages

A message defined in msgs/*.hmsg becomes a Rust struct, a C++ struct and a Python ctypes.Structure — all with the same layout and the same layout hash.

mkdir msgs

msgs/weather.hmsg:

/// A reading from the weather mast.
#[topic = "weather.data"]
WeatherData {
    /// Nanoseconds since the epoch.
    timestamp_ns: u64,
    /// Degrees Celsius.
    temperature: f32,
    humidity: f32,
    /// Last sixteen samples, oldest first.
    history: [f32; 16],
    heater_on: bool,
}
horus msg gen
* Generated 1 message(s) from 1 file(s)
    WeatherData              0x8b734db7  88 bytes (7 padding)

    .horus/generated/msgs/Cargo.toml
    .horus/generated/msgs/src/lib.rs
    .horus/generated/msgs_ffi/Cargo.toml
    .horus/generated/msgs_ffi/src/lib.rs
    .horus/generated/include/demo/msgs.hpp
    .horus/generated/python/msgs.py

Using it

Rust — the generated crate is already a dependency of your project:

use demo_msgs::WeatherData;

let topic: Topic<WeatherData> = Topic::new(WeatherData::TOPIC)?;
topic.send(WeatherData { temperature: 21.5, ..Default::default() });

C++ — the generated include path is already on your target's include path:

#include <demo/msgs.hpp>

demo::msg::WeatherData w{};
w.temperature = 21.5f;
w.set_heater_on(true);   // Rust's bool is one byte; the field is uint8_t

Publishing and subscribing use the generated wrappers, which are in the same header:

demo::msg::WeatherDataPublisher pub(demo::msg::WEATHERDATA_TOPIC);
if (pub.valid()) {
    pub.send(w);
}

demo::msg::WeatherDataSubscriber sub(demo::msg::WEATHERDATA_TOPIC);
demo::msg::WeatherData got{};
if (sub.recv(got)) {
    // got.temperature is whatever the publisher sent, in any language
}

Both are move-only — they own a handle, and copying one would release it twice — and a subscriber is single-threaded, because the transport's consumer contract is one reader per handle. valid() is false when the topic could not be opened.

The entry points behind them are generated into .horus/generated/msgs_ffi/ and linked automatically. They cannot come from libhorus_cpp.a the way the built-in types' do: adding symbols to that archive would mean editing and rebuilding the HORUS source tree.

Python:

import sys; sys.path.insert(0, ".horus/generated/python")
from msgs import WeatherData

w = WeatherData(temperature=21.5)

The layout hash

Every artifact carries the same number, and horus msg hash prints it:

$ horus msg hash WeatherData
0x8b734db7

It is FNV-1a over a canonical rendering of the definition:

WeatherData|timestamp_ns:u64|temperature:f32|humidity:f32|history:[f32; 16]|heater_on:bool

That string is embedded in the generated Rust as LAYOUT_CANONICAL rather than rebuilt with stringify!, which reproduces source spacing — [u8;32] and [u8; 32] would otherwise hash differently.

The generated C++ header asserts its own layout against the Rust one, field by field:

static_assert(sizeof(WeatherData) == 88, "WeatherData size differs from Rust");
static_assert(offsetof(WeatherData, humidity) == 12, "...");

If the two ever disagree, the C++ build stops. This is not hypothetical: the hand-maintained Rust and C++ definitions of JointCommand once drifted to 928 bytes against 88 — an 840-byte overrun on every receive, with no compiler anywhere in a position to notice.

What a message may contain

Everything crossing the boundary is #[repr(C)] and is read and written with a raw pointer copy, so a message must be plain data:

AllowedNot allowed
u8u64, i8i64, f32, f64, boolString, &str
[T; N] — a fixed-length arrayVec<T>
another message declared in msgs/Option<T>, Box<T>, HashMap
references, generics, enums

A rejected type is reported with the reason and what to write instead:

msgs/weather.hmsg:4:11: `String` cannot be used in a message
  help: a String owns a heap allocation, which cannot be shared between
        processes. Use a fixed array like `[u8; 64]`.

Built-in types

.hmsg cannot yet reference built-in types such as Vector3. Write the fields out — Vector3 is three f64s. The restriction is deliberate: sizing a built-in would mean this generator carrying a table of their layouts, and a table that is wrong produces a header whose static_assert passes against the wrong number. That is the failure the layout contract exists to catch.

In CI

--check verifies the generated files match the definitions without rewriting them, so a commit that edits a .hmsg and forgets to regenerate fails:

horus msg gen --check