ROS 2
Status: Available (offline MCAP and experimental live rmw_zenoh source). Thadam’s ROS 2 support is staged (ADR-0007). Stage 1 (thadam-ros2-msg, the transport-independent .msg parser and CDR decoder), Stage 2 (thadam-mcap, replaying MCAP and rosbag2-MCAP recordings, plus thadam export --mcap), Stage 3 (the live-transport spike), and Stage 4 (ros2_live, experimental behind the opt-in ros2-live feature) exist today.
| Stage | Status |
|---|---|
Stage 1: thadam-ros2-msg (.msg parser + CDR decoder) | Available |
Stage 2: MCAP / rosbag2 file source (thadam-mcap) | Available |
Stage 2: thadam export --mcap (thadam export) | Available |
Stage 2, rest: examples/ros2-mcap-to-parquet (MCAP to decoded, validated Parquet and back) | Available |
Stage 3: live-transport spike (Zenoh-native, sidecar, r2r) | Available (rmw_zenoh primary, bridge mapped) |
Stage 4: live ROS 2 source (ros2_live) | Available (rmw_zenoh transport, experimental, opt-in ros2-live feature) |
How it works
.msg definition text ──parse──▶ Registry ──compile──▶ CompiledMessage
(or a ros2msg schema document) │ │ Shape + decode plan
CDR payload ───────────────────────┴──── decode ──────▶ Record
- Parse.
Registry::register(package, name, definition)parses one.msgdefinition: the fifteen primitive types, bounded strings (string<=N,wstring<=N), fixed arraysT[N], bounded sequencesT[<=N], unbounded sequencesT[], and nested references (pkg/Type,pkg/msg/Type, or bareTypein the same package). Constants are parsed, range-checked, and kept on the definition but never encoded; default values (float64 w 1) are parsed and ignored for decoding;#comments are stripped.Registry::register_ros2msg(main_type, text)takes the concatenated multi-definition format carried by MCAP/rosbag2ros2msgschemas (the main definition, then================================================================================+MSG: pkg/Typesections). - Compile.
Registry::compile(type_name)resolves nested references and builds one ThadamShapeplus a decode plan per message type (cached; re-registering a type invalidates its entry). Nested scalar messages flatten to dotted field names:std_msgs/Header headerbecomesheader.stamp.sec,header.stamp.nanosec,header.frame_id, … Abuiltin_interfaces/Timefieldtadditionally produces atfield of typeTsholdingsec * 10^9 + nanosec(the value always fits; no normalization). Definition cycles and nesting beyond 32 levels are rejected at compile time. - Decode.
CompiledMessage::decode(&payload)decodes one XCDR1 CDR payload (encapsulation schemes0x0000big-endian and0x0001little-endian; XCDR2 is rejected asdecode.unsupported_encoding) into aRecordof that shape. Alignment is relative to the start of the payload after the 4-byte encapsulation header. Every read is bounds-checked; a corrupted declared length fails before any allocation (decode.invalid_payload), and sequences of zero-size element types are capped at 16 384 elements (decode.too_large). Malformed or truncated payloads always error — a proptest drives arbitrary bytes and encapsulation headers through every compiled shape; decoding never panics and never puts payload text in error messages.
Type mapping
byte is the ROS alias of int8, char of uint8.
| ROS 2 type | Thadam FieldType | Thadam Value |
|---|---|---|
bool | Bool | Bool (any non-zero byte is true) |
byte | I8 | I8 |
char | U8 | U8 |
int8 … int64 | I8 … I64 | matching |
uint8 … uint64 | U8 … U64 | matching |
float32 / float64 | F32 / F64 | matching |
string, wstring, string<=N, wstring<=N | Str | Str (wstring re-encoded UTF-16→UTF-8; bounds enforced) |
builtin_interfaces/Time t | t.sec: I32, t.nanosec: U32, t: Ts | sec/nanosec plus the timestamp |
byte[], char[], uint8[] | Bytes | Bytes (raw bytes) |
float32 array | List(F32) | F32s typed array |
float64 array | List(F64) | F64s typed array |
int8 / int16 / uint16 / int32 array | List(I32) | I32s (exact widen to i32) |
int64 / uint32 array | List(I64) | I64s (exact widen to i64) |
uint64 array | List(U64) | List (no wider signed typed array exists) |
bool array | List(Bool) | List |
string / wstring array | List(Str) | List |
pkg/Type array (nested message) | List(Record(shape)) | List of Record |
Array rules: all three bound forms (T[], T[<=N], T[N]) decode to the same container; fixed arrays carry no length prefix on the wire. Widening rule: narrower integers decode into the nearest typed array that holds every value — int8/int16/uint16 land in I32s, uint32 in I64s; uint64 has no wider signed typed array and stays a List of U64. Nested-message arrays decode to a List of Record values whose shape is the recursively flattened element type (a Time scalar inside still yields the sec/nanosec/Ts triple).
Wire details and limits
- A CDR
stringis auint32length including the trailing NUL, then the bytes; a missing NUL or invalid UTF-8 is an error (decode.invalid_payload,decode.invalid_utf8). Awstringis auint32UTF-16 code-unit count plus units; one trailing NUL unit is tolerated. Declared<=Nbounds are enforced. float64[9]and other fixed arrays read exactlyNelements with no length.- Trailing bytes are ignored, so a padded transport frame still decodes.
- Compile-time limits: 4 096 fields per shape (Thadam core), 32 levels of message nesting.
API sketch
let mut registry = Registry::new();
registry.register_ros2msg("sensor_msgs/JointState", &mcap_schema_text)?;
let msg = registry.compile("sensor_msgs/JointState")?; // cached
let shape = msg.shape(); // header.stamp, position, …
let record = msg.decode(&payload)?; // Record or ThadamError
The parsed definition, including constants, is available as registry.spec(type_name). See the crate rustdoc for the full surface. Performance on the reference machine (Apple Silicon): sensor_msgs/JointState (6 joints) decodes at roughly 2.4 M messages/s (~420 ns each), sensor_msgs/Imu at roughly 3.5 M messages/s (~290 ns each) — see cargo bench -p thadam-ros2-msg.
MCAP file source
The mcap source replays one or more MCAP files — including rosbag2’s MCAP storage format — as one finite stream, merged in log-time order. Built as the thadam-mcap crate on the pure-Rust mcap crate (with the zstd and lz4 chunk decompressors), satisfying ADR-0007 stage 2. It is a file source: there is no broker, no session, and nothing to acknowledge.
sources:
- id: rosbag
kind: mcap
config:
paths: [/bags/run-12, /bags/run-13.mcap]
topics: [/joint_states, /tf/]
start: 2026-10-01T12:00:00Z
end: 1_759_363_200_000_000_000
pace: realtime
decode: ros2
Configuration
Unknown keys are rejected. paths is the only required key.
| Key | Type | Default | Description |
|---|---|---|---|
paths | list | — | Required, non-empty. MCAP files, or directories scanned one level deep for *.mcap. Duplicates are removed, the list is sorted, and all files are merged into one stream. |
topics | list | all | Topic allow-list: exact names, or prefixes when the entry ends with / (/tf/ matches /tf/static but /tf is not a prefix). |
start / end | log time | unbounded | Inclusive bounds on the MCAP log time: an RFC 3339 string or integer nanoseconds since the Unix epoch. start must not be after end. |
pace | max, realtime, or a positive number | max | Replay pace. max reads as fast as possible; realtime preserves the original inter-message gaps; a number is a time factor (2.0 halves gaps, 0.5 doubles them). A single gap never sleeps longer than a day. |
decode | raw or ros2 | raw | See below. Payloads stay raw in both modes. |
paths may name files being copied into place: a missing file is a transient connector.connect_failed error, so the run retries; a directory with no *.mcap in it fails validation as config.invalid. A truncated or corrupt file (bad magic, a cut record, a failed chunk CRC, unsupported compression) is a permanent, classified connector.protocol error, never a panic — and a file that ends before its DataEnd record is treated as truncated, so partial replays never pass silently as complete ones.
decode: raw versus decode: ros2
The source never turns payload bytes into records — that is the transform chain’s job, matching ADR-0007’s separation of transport and schema. The difference:
rawpasses every channel through untouched.ros2additionally pre-flights each channel whose schema encoding isros2msg: the schema is registered and compiled when the channel is first seen, so a schema that does not compile failsopen(aschema.*error naming the file) instead of surprising the pipeline mid-run. Messages on such channels additionally carrymcap.ros2.fields(see below).
The schema store and decode: ros2 on a dataset
In both modes the source writes every ros2msg channel’s schema text to the pipeline’s schema store the first time the channel is seen — durable (tmp file, fsync, rename, directory fsync), content-addressed, and idempotent — before the first message referencing it can be committed to the WAL, so a replay never lands a message whose schema was lost. The store lives at <data_dir>/schemas/ (thadam_core::SchemaStore, one <sha-256 hash>.schema file per schema holding name, encoding, and text); a store write failure is a classified error, never silently skipped. Every message on such a channel carries the schema’s hash as mcap.schema_hash.
A dataset then opts into ROS 2 decoding:
datasets:
- id: joints
match: { source: rosbag, topic: /joint_states }
decode: ros2
time: { field: /header.stamp, unit: ns, domain: utc }
fields:
- { id: 1000, name: frame, type: str, from: /header.frame_id }
- { id: 1001, name: position, type: list<f64>, from: /position }
The chain resolves the schema text by the message’s mcap.schema_hash — once per hash, since the store is content-addressed, so a compiled-cache hit costs no store read — decodes the CDR payload with thadam-ros2-msg, and reads the decoded record directly: a dataset payload pointer that is a single JSON Pointer token (/header.stamp, /pose.position.x, the documented spelling) resolves to one field lookup on the record, with the declared-field coercion running on the record’s own values, so a Ts stays a timestamp and Bytes stay bytes. Failures are quarantined rejects with classified codes, never silent drops, and error text never contains payload bytes: a missing mcap.schema_hash is decode.schema_ref_missing, a hash the store does not hold is decode.unknown_schema, and schema or CDR problems are the schema.*/decode.* codes the decoder already classifies.
Only the pointers a record cannot serve fall back to spelling the record as one flat JSON object (one key per dotted name), materialized per message that needs it: nested or indexed multi-token pointers such as /name/0, and explode over a typed array (the JSON spelling treats it as an array of scalars). One behavior change falls out of the direct read: non-finite floats used to become null in the JSON spelling — a pose step read them as pose_missing_field, and a NaN typed array was rejected at extraction. They now reach pose/joint_state as non-finite values and are flagged pose_not_finite/joint_state_not_finite as designed; required float fields no longer reject the message for them.
Dotted names may be declared as dataset fields: each is one literal flat column (pose.position.x), and an omitted from defaults to /<name> as a single JSON Pointer token — the spelling the direct record read serves — see dataset fields. The robotics conventions page has a complete PoseStamped dataset example.
Ordering, timing, and replay determinism
Files are merged by MCAP log time; ties break by sorted file path, then write order. Within one file the stream follows file (write) order — recorders write log-time-ordered streams. With pace: max, each message’s ingest time is set to its MCAP log time, so replaying the same inputs lands byte-identical in the WAL: replays are deterministic. With a paced replay the ingest time is the wall clock at emission, like a live capture. Pacing follows emitted messages: a filtered-out message does not contribute to gaps, and out-of-order log times never sleep negative. BatchLimits.linger bounds how long one paced pull waits to fill its batch, exactly as for the network sources.
Messages, attributes, and acknowledgements
Each MCAP message becomes one raw message with these attributes:
| Attribute | Value |
|---|---|
topic | The channel’s topic (the normalized attribute read by match.topic filters and {topic.N} identity templates). |
mcap.topic | The channel’s topic, namespaced. |
mcap.schema_name | The schema name (for example sensor_msgs/msg/JointState), or "" for schemaless channels. |
mcap.schema_encoding | For example ros2msg or jsonschema, or "". |
mcap.message_encoding | For example cdr or json. |
mcap.channel_id | The channel id, decimal. |
mcap.sequence | The recorder’s sequence number, decimal. |
mcap.log_time | The MCAP log time, nanoseconds, decimal. |
mcap.publish_time | The MCAP publish time, nanoseconds, decimal. |
mcap.ros2.fields | With decode: ros2 only, and only on channels with a ros2msg schema: the comma-separated flattened field names of the compiled shape (for example header.stamp.sec,header.stamp.nanosec,header.stamp,header.frame_id,name,position,velocity,effort). |
mcap.schema_hash | On every message of a channel with a ros2msg schema: the hex SHA-256 key of the channel’s schema in the pipeline’s schema store. The chain’s dataset decode: ros2 resolves schema text with it. |
Timestamps
ROS 2 has two clocks you can choose from in time:
- Publisher time, from the message:
header.stamp(abuiltin_interfaces/Time, decoded to unix nanoseconds). Usefield: /header.stamp, unit: ns. The dotted name is one JSON Pointer token, because the decoded field is the flattened keyheader.stamp. - Recorder time, from MCAP:
attr: mcap.log_time, unit: ns. Publish time is also available asattr: mcap.publish_time, unit: ns.
time.field reads the decoded record too: with dataset decode: ros2, /header.stamp addresses the flattened key the decoder writes (a nanosecond Ts, read as-is with unit: ns), exactly as the field paths above do.
The source is finite: it ends with None once the inputs are drained.
Restarts and resume
Reading a file again is not harmless: each re-read message lands at a new WAL position, so its default event id differs and sinks would receive duplicate rows. The source therefore resumes instead. It acknowledges after the WAL commit (ack_mode: on_commit) and, on each acknowledgement, atomically writes resume.json into its component directory, <data_dir>/sources/<source id>/ (a temporary file, fsync, rename, directory fsync). For every input file the state holds the file’s path, size, and modification time, and how many of its messages (counted in file order, before the topics and time filters) the committed batches cover.
On the next start with the same data directory, a completed file is skipped and a partly read file continues after its last committed message. Batches the runtime had not yet acknowledged are read again, so no uncommitted message is skipped. Two caveats:
- A crash in the short window between a batch’s WAL commit and the state write can repeat that window’s batches: delivery is at-least-once, the same contract as the other acknowledged sources.
- Resume trusts a file only if its path, size, and modification time are unchanged, and only if
topics,start, andendare unchanged. Otherwise that file (or, for a changed selection, every file) is replayed from the start and the source logs a warning. To replay everything on purpose, deleteresume.json.
A resume.json that cannot be read or parsed fails the run (buffer.io or buffer.corrupt) instead of silently starting over, and a state write that fails stops the run with buffer.io.
Performance note
Files stream from disk one chunk at a time (peak memory is one chunk record plus its decompressed form, with record and decompressed chunk sizes capped at 64 MiB; the reader is safe Rust throughout — no mmap). Chunk reads and decompression run synchronously inside batch pulls; per-chunk cost is small next to a WAL commit.