Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ bazel_dep(name = "googletest", version = "1.17.0.bcr.2", repo_name = "com_google
bazel_dep(name = "protobuf", version = "34.1", repo_name = "com_google_protobuf")

bazel_dep(name = "rules_cc", version = "0.2.17")
bazel_dep(name = "rules_shell", version = "0.8.0")
bazel_dep(name = "rules_pkg", version = "1.0.1")
bazel_dep(name = "zlib", version = "1.3.1.bcr.5")

Expand Down
3 changes: 2 additions & 1 deletion MODULE.bazel.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

207 changes: 188 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ wire-format, instead of in a tree of heap-allocated objects. Once a message is b
it can be written to disk, placed in shared memory, or sent over an IPC system **without
a serialization step** — the bytes in the buffer *are* the message.

The generated API is intentionally almost identical to the standard protobuf C++ API, so
if you know protobuf, you already know Phaser.
Choose a familiar protobuf-style C++ API or a ROS-style public-field API. Both
frontends use the same native payload and expose the same protobuf and ROS wire
conversion backends.

> 📖 For the full reference, see the **[Phaser User Guide](phaser/docs/phaser_user_guide.md)**.

Expand Down Expand Up @@ -47,20 +48,26 @@ costs nothing until you actually touch a field.
interoperate via per-message field metadata.
- **Protobuf wire-format transcoding** is available when you *do* need it (e.g. storing in
systems like BigQuery that expect protobuf bytes).
- **Familiar API** — same accessor names as protobuf, with extra zero-copy helpers.
- **Two C++ frontends** — protobuf-style accessors or ROS-style public field proxies.
- **Direct wire-to-wire conversion** between protobuf and ROS1 without constructing
an intermediate user-facing message.
- **Allocation-free receive paths** when native payloads and wire conversions use
caller-provided buffers.

## Features

1. proto3 (primary) and proto2 IDL support
2. Message printing to `std::ostream`
3. Fixed- and variable-sized buffers
4. User-supplied per-buffer metadata
5. Full `google.protobuf.Any` support (zero-copy)
6. Enum printing and parsing
7. Message reflection
8. Field presence masks
9. Bazel build integration
10. Modern C++17 with [Abseil](https://abseil.io)
2. Protobuf and ROS-style generated C++ frontends
3. Native Phaser, protobuf, and ROS1 wire input/output
4. Direct protobuf-to-ROS and ROS-to-protobuf backend conversion
5. Fixed, caller-owned, and dynamically growing buffers
6. Allocation-free typed receive, traversal, and fixed-buffer transcoding
7. Full zero-copy `google.protobuf.Any` support
8. Repeated vectors, ROS fixed-array facades, and variant-like ROS `oneof` fields
9. ROS1 `Time`, `Duration`, and `Header` intrinsic mappings
10. Hybrid dense/sparse field metadata and protobuf version compatibility
11. Reflection, enum conversion, field presence, and user metadata
12. Bazel integration and modern C++17 with [Abseil](https://abseil.io)

## How it works

Expand All @@ -85,9 +92,14 @@ The key idea is the split between two representations:

- When you call `set_x(...)`, the value is written **directly into the binary buffer**.
- When you call `x()`, the value is read back **from the binary buffer**, located via a
small per-message **field-metadata** array. That indirection is what enables protobuf's
version compatibility: a reader built with a different schema version can still find the
fields present in the data.
compact per-message **field-metadata** index. Dense field-number ranges use direct lookup;
outliers use binary search. That indirection is what enables protobuf's version
compatibility: a reader built with a different schema version can still find the fields
present in the data.

The hybrid metadata layout is a new native Phaser payload format. New runtimes can
read legacy payload metadata, but older runtimes cannot read newly generated native
payloads. Protobuf and ROS wire formats are unchanged.

The `PayloadBuffer` (from the [cpp_toolbelt](https://github.com/dallison/cpp_toolbelt)
library) is a relocatable heap — a malloc/free/realloc allocator that uses only offsets
Expand All @@ -114,6 +126,8 @@ proto_library(
phaser_library(
name = "foo_phaser",
add_namespace = "phaser", # optional: avoids clashing with protobuf classes
frontend = "protobuf", # default; use "ros" for public field proxies
enable_active_message = False, # optionally add std::any active_message
deps = [":foo_proto"],
)
```
Expand All @@ -126,6 +140,86 @@ any protobuf header:
#include "foo/bar/Foo.phaser.h"
```

Phaser can generate either the default protobuf-style accessors or a ROS-style
struct interface:

```python
phaser_library(
name = "foo_ros_phaser",
add_namespace = "ros_api",
frontend = "ros",
deps = [":foo_proto"],
)
```

The frontend changes only the C++ access syntax:

```c++
// frontend = "protobuf"
foo::bar::phaser::Foo protobuf_api;
protobuf_api.set_count(3);
protobuf_api.set_name("sensor");
protobuf_api.add_samples(1.5);

// frontend = "ros"
foo::bar::ros_api::Foo ros_api;
ros_api.count = 3;
ros_api.name = "sensor";
ros_api.samples.push_back(1.5);
```

Both frontends preserve the same native payload layout. Code generated from the
same schema can therefore attach to the same received bytes with
`CreateReadonly`, regardless of which frontend produced them. Both also expose
protobuf serialization, ROS1 serialization, and the direct wire conversion
APIs described below.

Set `enable_active_message = True` if each generated source object should also
carry an application-owned `std::any active_message`. This transient member is
not part of the native payload and is not serialized.

Fixed-size ROS fields are repeated protobuf fields annotated with
`[(phaser.array_size) = N]`; import `phaser/options.proto` in the schema. ROS
`oneof` fields expose a variant-like proxy with generated alternative tags.
See the user guide for the complete array and oneof APIs.

The ROS frontend also maps singular `google.protobuf.Timestamp`,
`google.protobuf.Duration`, and `std_msgs.Header` message fields to
`ros::Time`, `ros::Duration`, and `std_msgs::Header`. Mutable Header access
remains compatible with existing ROS1 reference-taking functions. Read-only
Header access returns `phaser::RosHeaderView`, whose `frame_id` is a
`std::string_view`; call `ToOwned()` when an owning `std_msgs::Header` is
required. Add the corresponding ROS C++ targets through the `cc_deps` attribute.

Every generated message, in either frontend style, can also produce and consume
ROS1 wire bytes:

```c++
::phaser::ROSBuffer ros_output;
absl::Status status = msg.SerializeToROS(ros_output);

// Convert serialized protobuf or a native Phaser payload without first
// constructing the user-facing message.
status = Foo::ProtobufToROS(protobuf_bytes, ros_output);
::phaser::ProtoBuffer protobuf_output(output_data, output_capacity);
status = Foo::ROSToProtobuf(ros_bytes, protobuf_output);
status = Foo::PhaserToROS(phaser_bytes, ros_output);
status = Foo::ConvertToROS(input_bytes, ros_output); // infers input format

// Decode received ROS1 bytes directly into msg's native PayloadBuffer.
status = msg.ParseFromROS(ros_bytes);
```

`ROSBuffer` can own a growing allocation or wrap caller-provided output memory.
`ROSReader` provides bounds-checked input decoding. ROS wire data uses
little-endian ROS1 layout. Phaser's custom `oneof` layout writes a `uint32`
protobuf field-number discriminator before the selected arm, with zero meaning
unset, so it can be decoded without an external arm selection.
`InferMessageWireFormat` validates both the protobuf structure and Phaser
payload header; magic alone is not enough because the same four-byte prefix can
begin a valid protobuf tag. Malformed or genuinely ambiguous input is reported
explicitly.

### 2. Create and use a message

Creating a message looks just like protobuf — the binary data is backed by a dynamic
Expand Down Expand Up @@ -154,6 +248,27 @@ auto msg = foo::bar::phaser::TestMessage::CreateReadonly(buffer, size);
int x = msg.x();
```

Caller-buffer `CreateMutable`, typed mutation, `CreateReadonly`, typed field
traversal, typed `Any`, caller-buffer protobuf serialization, and fixed-buffer
ROS serialization do not use the system heap. Runtime bookkeeping and generated
type metadata live inside the `PayloadBuffer`; fixed-buffer creation therefore
needs enough room for both message data and this small control data. Repeated
strings iterate as `std::string_view`; repeated-message
indexing and iteration return lightweight message handles by value. These views
remain valid while the caller-owned receive buffer remains alive and unchanged.
Protobuf/ROS deserialization into a fixed mutable message and
`ProtobufToROS` with a sufficiently large fixed `ROSBuffer` are also
system-heap-allocation-free, including registered `Any` payloads.
`ProtobufToROS` scans protobuf wire fields directly and emits ROS bytes without
constructing an intermediate protobuf or Phaser message.
`ROSToProtobuf` performs the reverse direct conversion through `ROSReader` and
`ProtoWriter`; nested and packed protobuf lengths are computed with allocation-
free counting passes. ROS Header decoding writes `frame_id` directly from its
wire view into payload storage without an owning `std::string` intermediate.
Dynamic messages, reflection/debug output, unknown `Any` error handling, and
explicit owning conversions such as `RosHeaderView::ToOwned()` are outside this
guarantee.

### 3. Zero-copy field access

Beyond the standard protobuf accessors, Phaser adds helpers that hand you the final storage
Expand All @@ -167,8 +282,8 @@ absl::Span<char> dst = msg.allocate_s(len);
msg.resize_vi32(n);
absl::Span<int32_t> data = msg.vi32_as_mutable_span();

// Repeated messages: allocate many at once (one allocation).
std::vector<InnerMessage*> items = msg.allocate_vm(n);
// Repeated messages: allocate many at once (one payload allocation).
std::vector<InnerMessage> items = msg.allocate_vm(n);
```

## Protobuf interoperability
Expand All @@ -189,6 +304,59 @@ bool ParseFromString(const std::string& str);
binary message you can access directly (via `Is<T>()` / `As<T>()` / `MutableAny<T>()`), with
`PackFrom` / `UnpackTo` provided for protobuf-compatible copying.

## Wire-to-wire backend conversions

Wire conversion APIs are generated for every message in both frontend styles.
They scan the source wire format and write the destination format directly;
they do not construct an intermediate protobuf object or Phaser source message.

Convert protobuf bytes directly to ROS1:

```c++
std::string protobuf_wire = GetProtobufBytes();
::phaser::ROSBuffer ros_output; // owns a growing output buffer
absl::Status status =
foo::bar::phaser::Foo::ProtobufToROS(protobuf_wire, ros_output);
if (status.ok()) {
SendROS(ros_output.data(), ros_output.size());
}
```

Convert ROS1 bytes directly to protobuf using caller-owned output memory:

```c++
absl::Span<const char> ros_wire = ReceiveROS();
std::array<char, 64 * 1024> storage;
::phaser::ProtoBuffer protobuf_output(storage.data(), storage.size());

absl::Status status =
foo::bar::phaser::Foo::ROSToProtobuf(ros_wire, protobuf_output);
if (status.ok()) {
SendProtobuf(storage.data(), protobuf_output.Size());
}
```

Convert a native Phaser payload to ROS1, or let Phaser distinguish native and
protobuf input:

```c++
const auto* data = static_cast<const char*>(msg.Data());
absl::Span<const char> native(data, msg.Size());

::phaser::ROSBuffer ros_output;
absl::Status status =
foo::bar::phaser::Foo::PhaserToROS(native, ros_output);

// Accept either protobuf wire bytes or a native Phaser payload.
status = foo::bar::phaser::Foo::ConvertToROS(input, ros_output);
```

`ProtobufToROS` and `ROSToProtobuf` preserve schema order, recursively convert
nested messages, transform protobuf varints when required, and bulk-copy
wire-compatible packed fixed-width arrays. With fixed `ROSBuffer` and
`ProtoBuffer` instances, successful conversion performs no system-heap
allocation. `ParseFromROS` is the corresponding ROS1-to-native operation.

## The Phaser Bank (type-erased operations & reflection)

The **Phaser Bank** lets you operate on messages given only their type *name* — stream,
Expand Down Expand Up @@ -235,13 +403,14 @@ and dependencies are available:

```bash
protoc --plugin=protoc-gen-phaser=DIR/bin/phaser/compiler/phaser \
--phaser_out=add_namespace=NS,package_name=PACKAGE,target_name=TARGET:OUTPUT_DIR \
--phaser_out=frontend=ros,add_namespace=NS,package_name=PACKAGE,target_name=TARGET:OUTPUT_DIR \
-I IPATH \
FILE...
```

Output is written to `OUTPUT_DIR/PACKAGE/TARGET`. See the user guide for the full argument
reference.
reference. Use `frontend=protobuf` for the default accessor API and optionally
add `active_message=true`.

## Project layout

Expand Down
Loading
Loading